From 8ba826d51d4ef1db1f41f10efb570083094b4d5a Mon Sep 17 00:00:00 2001 From: Paul Hogan <5004d00b753726762516a66df75936687319095eacf0e9f7a4710f7a0ff12098@meshllm.communities.buzz.xyz> Date: Wed, 9 Sep 2026 10:42:24 +1000 Subject: [PATCH 01/41] fix(skippy): default BUILTIN_UBATCH to 512 to clear the CUDA SSM SSD gate The 128 default (PR #564, no recorded rationale) diverged from llama.cpp's own LLAMA_SERVER_DEFAULT_N_UBATCH = 512 and missed the CUDA SSM SSD kernel gate (n_tok > SSM_SSD_MIN_TOKENS, 128, strict) by exactly one token on every default recurrent prefill, forcing the sequential-scan fallback. Measured on granite-4.0-h-1b (2026-09-08 competitive bench, same binary and protocol): TTFT p50 0.670 -> 0.415 s (C1) and 6.38 -> 3.97 s (C8), C8 decode 22.2 -> 39.4 tok/s. Dense negative control (Qwen3-1.7B) flat. Cost: +203 MiB CUDA compute buffer. Also aligns the gpu-tune planner copy, corrects the setting description (physical prefill chunk size, not decode micro-batch), and forwards the resolved n_ubatch / flash_attn llama_context lines into mesh.log so config landing is observable without buffer-size fingerprinting. --- .../src/gpus/tune/apply_write_tests.rs | 6 +-- .../src/gpus/tune/planning.rs | 2 +- .../tune/recommendation_defaults_tests.rs | 2 +- .../src/model/built_in_schema/presentation.rs | 2 +- .../src/inference/skippy/resolver/tests.rs | 2 +- .../src/inference/skippy/resolver/types.rs | 8 +++- .../configuration-defaults-runtime.ts | 3 +- crates/skippy-runtime/src/logging.rs | 44 +++++++++++++++++++ 8 files changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/mesh-llm-commands/src/gpus/tune/apply_write_tests.rs b/crates/mesh-llm-commands/src/gpus/tune/apply_write_tests.rs index a4679c75a3..df80ed727f 100644 --- a/crates/mesh-llm-commands/src/gpus/tune/apply_write_tests.rs +++ b/crates/mesh-llm-commands/src/gpus/tune/apply_write_tests.rs @@ -72,7 +72,7 @@ fn gpu_tune_apply_preserves_comments_and_writes_nested_fields() { .lines() .any(|line| line.trim() == "flash_attention = \"enabled\"") ); - assert!(!prefix.lines().any(|line| line.trim() == "ubatch = 128")); + assert!(!prefix.lines().any(|line| line.trim() == "ubatch = 512")); assert!( model_fit_section .lines() @@ -91,7 +91,7 @@ fn gpu_tune_apply_preserves_comments_and_writes_nested_fields() { assert!( model_fit_section .lines() - .any(|line| line.trim() == "ubatch = 128") + .any(|line| line.trim() == "ubatch = 512") ); assert!( !model_fit_section @@ -246,5 +246,5 @@ fn gpu_tune_replace_existing_writes_nested_recommendations_over_legacy_manual_fi assert_eq!(model_fit.cache_type_v.as_deref(), Some("q8_0")); assert_eq!(model_fit.ctx_size, Some(65_536)); assert_eq!(model_fit.batch, Some(512)); - assert_eq!(model_fit.ubatch, Some(128)); + assert_eq!(model_fit.ubatch, Some(512)); } diff --git a/crates/mesh-llm-commands/src/gpus/tune/planning.rs b/crates/mesh-llm-commands/src/gpus/tune/planning.rs index 1a7aa1f427..bb1d331bf3 100644 --- a/crates/mesh-llm-commands/src/gpus/tune/planning.rs +++ b/crates/mesh-llm-commands/src/gpus/tune/planning.rs @@ -1,7 +1,7 @@ use super::*; const BUILTIN_BATCH: u32 = 512; -const BUILTIN_UBATCH: u32 = 128; +const BUILTIN_UBATCH: u32 = 512; const BUILTIN_SAFETY_MARGIN_GB: f64 = 2.0; const LARGE_MODEL_MIN_BYTES: u64 = 50 * 1024 * 1024 * 1024; const MIN_AUTO_CONTEXT_LENGTH: u32 = 512; diff --git a/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs b/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs index 6adbbe9f36..04feaf7ec8 100644 --- a/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs +++ b/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs @@ -21,7 +21,7 @@ fn gpu_tune_recommends_stable_defaults() { assert_applied_flash_attention(&plan, TuneFlashAttentionValue::Enabled); assert_applied_context(&plan, 131_072); assert_applied_batch(&plan, 512); - assert_applied_ubatch(&plan, 128); + assert_applied_ubatch(&plan, 512); assert_applied_gpu_layers(&plan, TuneGpuLayersValue::All); assert_applied_fit_target(&plan, 22 * 1024); } diff --git a/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs b/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs index fffb8596b0..8a10ee5d74 100644 --- a/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs +++ b/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs @@ -453,7 +453,7 @@ fn runtime_defaults_presentation(rendered: &str) -> Option .hint("range")), "defaults.model_fit.ubatch" => Some(sp( "Micro-batch size", - "Set the default decode micro-batch size.", + "Set the default micro-batch (physical prefill chunk) size. Values at or below 128 keep the CUDA SSM sequential-scan fallback; larger values enable the SSD chunked kernel for recurrent models.", MEMORY_CATEGORY, 50, ) diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/tests.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/tests.rs index 11e120a260..9193a99f52 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/tests.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/tests.rs @@ -384,7 +384,7 @@ tuning_profile = "throughput" assert_eq!(resolved.model_fit.kv_offload, "true"); assert_eq!(resolved.throughput.tuning_profile, "throughput"); assert_eq!(resolved.model_fit.batch, 1024); - assert_eq!(resolved.model_fit.ubatch, 256); + assert_eq!(resolved.model_fit.ubatch, 1024); assert_eq!(resolved.throughput.parallel, 2); assert_eq!(resolved.throughput.continuous_batching, "true"); assert_eq!(resolved.hardware.fit_target_mib, Some(10_752)); diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/types.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/types.rs index 61ac28609f..93f745ce71 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/types.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/types.rs @@ -8,7 +8,13 @@ use crate::plugin::{MeshConfig, ReasoningBudget, ReasoningEnabled, RequestDefaul pub(super) const BUILTIN_CTX_SIZE: u32 = 4096; pub(super) const BUILTIN_BATCH: u32 = 512; -pub(super) const BUILTIN_UBATCH: u32 = 128; +/// Matches llama.cpp's own default (`LLAMA_SERVER_DEFAULT_N_UBATCH = 512`) and clears +/// the CUDA SSM SSD kernel gate (`n_tok > SSM_SSD_MIN_TOKENS`, 128, strict), which the +/// previous 128 default missed by exactly one token — forcing every recurrent (mamba) +/// prefill onto the sequential scan fallback. Measured on granite-4.0-h-1b: TTFT p50 +/// 0.670 → 0.415 s (C1) and 6.38 → 3.97 s (C8); decode 22.2 → 39.4 tok/s at C8. +/// See WHITE_UBATCH_512_FALSIFICATION_2026_09_08 in the 2026-09-08 competitive bench. +pub(super) const BUILTIN_UBATCH: u32 = 512; pub(super) const BUILTIN_PARALLEL: usize = 32; pub(super) const BUILTIN_PREFILL_CHUNK_SIZE: usize = 64; pub(super) const BUILTIN_PREFILL_ADAPTIVE_START: usize = 64; diff --git a/crates/mesh-llm-ui/src/features/app-tabs/configuration-defaults-runtime.ts b/crates/mesh-llm-ui/src/features/app-tabs/configuration-defaults-runtime.ts index b4f1bfdac1..0dfe146543 100644 --- a/crates/mesh-llm-ui/src/features/app-tabs/configuration-defaults-runtime.ts +++ b/crates/mesh-llm-ui/src/features/app-tabs/configuration-defaults-runtime.ts @@ -411,7 +411,8 @@ export const CONFIGURATION_DEFAULT_RUNTIME_SETTINGS = [ categoryId: 'memory', icon: 'layers', label: 'Micro-batch size', - description: 'Set the default decode micro-batch size.', + description: + 'Set the default micro-batch (physical prefill chunk) size. Values at or below 128 keep the CUDA SSM sequential-scan fallback; larger values enable the SSD chunked kernel for recurrent models.', inheritedLabel: 'Applied when a placement does not override micro-batch size', visibility: 'advanced', tomlSection: MODEL_FIT_TOML_SECTION, diff --git a/crates/skippy-runtime/src/logging.rs b/crates/skippy-runtime/src/logging.rs index 1df0480221..4f4d6d9eb8 100644 --- a/crates/skippy-runtime/src/logging.rs +++ b/crates/skippy-runtime/src/logging.rs @@ -472,6 +472,19 @@ fn summarize_native_log_line(line: &str) -> Option { }); } + if line.starts_with("llama_context: n_ubatch") || line.starts_with("llama_context: flash_attn") + { + // Forward the resolved micro-batch size and flash-attention mode so a live + // deployment can prove which values the runtime actually constructed with. + // These lines come from the llama_context parameter dump + // (llama-context.cpp, `n_ubatch = ...` / `flash_attn = ...`). + return Some(NativeLogEvent { + message: line.to_string(), + category: "runtime", + params: Vec::new(), + }); + } + if line.contains("VRAM") || line.contains("vram") || line.contains("mem_alloc") @@ -941,6 +954,37 @@ mod tests { ); } + #[test] + fn aggregator_forwards_llama_context_config_lines() { + let mut aggregator = NativeLogAggregator::default(); + assert_eq!( + aggregator.process_line("llama_context: n_ubatch = 512"), + vec![NativeLogEvent { + message: "llama_context: n_ubatch = 512".to_string(), + category: "runtime", + params: Vec::new(), + }] + ); + assert_eq!( + aggregator.process_line("llama_context: flash_attn = enabled"), + vec![NativeLogEvent { + message: "llama_context: flash_attn = enabled".to_string(), + category: "runtime", + params: Vec::new(), + }] + ); + assert!( + aggregator + .process_line("llama_context: n_ctx = 8192") + .is_empty() + ); + assert!( + aggregator + .process_line("llama_context: causal_attn = 1") + .is_empty() + ); + } + #[test] fn aggregator_ignores_non_backend_cuda_mentions() { let mut aggregator = NativeLogAggregator::default(); From 7af1faceff8f7207f41700c345cc239d023cbbfe Mon Sep 17 00:00:00 2001 From: jian yang <27684d585da499cbdba179b1747db3283a668f79e4e78e1399f080d87898627b@meshllm.communities.buzz.xyz> Date: Wed, 9 Sep 2026 14:04:13 +1000 Subject: [PATCH 02/41] evals: add KV restart replay harness for #1647 Frozen-conversation benchmark that measures serving latency across a full process restart: fill (cold server, growing multi-turn prefix), restore (SIGINT, fresh serve on the same state directory), and warm (repeat replay without restart). Server starts with production defaults; the only extra arguments are an explicit --serve-extra-args pass-through so a durable KV tier can be A/B-measured without touching the harness. Per-run provenance (source SHA, binary/model SHA-256, hardware fingerprint, manifest SHA-256) plus JSONL request rows and a Markdown report land in the output directory. Verified end to end on darwin/aarch64 (Apple M2, SmolLM2-135M-Instruct Q8_0): fill cache 61%, restore cohort captured across a measured 7s restart, warm cache 100%, zero failed requests. --- evals/kv-restart-replay.py | 649 +++++++++++++++++++++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 evals/kv-restart-replay.py diff --git a/evals/kv-restart-replay.py b/evals/kv-restart-replay.py new file mode 100644 index 0000000000..db2652495d --- /dev/null +++ b/evals/kv-restart-replay.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +"""KV restart replay: measure serving latency across a process restart. + +Issue #1647-A. Runs one frozen multi-turn conversation against a default-startup +``mesh-llm serve`` in three cohorts: + +- ``fill`` — cold server, conversation grows turn by turn (prefix reuse). +- ``restore`` — the server is stopped and restarted on the same state directory, + then the frozen full conversation is replayed. On a build with a + durable KV tier this measures first-request-after-restart + restoration; without one it is the cold-prefill reference. +- ``warm`` — repeat replays without restart (resident reuse reference). + +The runner never sets context size, lanes, KV budget, or backend tuning; the +only serving arguments are ``--model``, ``--log-format json`` and the explicit +``--serve-extra-args`` pass-through an operator asks for (for example a +``--kv-cache-disk`` mode under test). Everything measured is observational: +streaming TTFT from the first chunk, usage from ``stream_options.include_usage``, +cached tokens from ``prompt_tokens_details``. + +Artifacts: ``run.json`` (schema_version 1, provenance + config + cohort +summaries), ``requests.jsonl`` (one row per request), ``report.md`` (human +summary). The manifest itself is deterministic from ``--turns`` and +``--turn-target-tokens`` and is embedded (with its SHA-256) into ``run.json``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import http.client +import json +import math +import os +import re +import signal +import socket +import statistics +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional, Sequence + +REPO = Path(__file__).resolve().parents[1] +DEFAULT_BASE_URL = "http://127.0.0.1:9337/v1" +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 9337 +SCHEMA_VERSION = 1 + +# Same discipline as evals/agentic-replay.py: a default-startup benchmark may +# not tune the server. Extra serving arguments must arrive explicitly via +# --serve-extra-args and are recorded verbatim in run.json. +FORBIDDEN_STARTUP_OPTIONS = ( + "--ctx-size", + "--generation-concurrency", + "--generation-queue-capacity", + "--max-vram", + "--parallel", +) + +# Deterministic manifest vocabulary. The conversation simulates a long-running +# coding-agent session: a stable scaffold, a growing project brief, and a +# per-turn request. Content is drawn from a fixed word list with a fixed PRNG +# seed so the same settings always produce the same conversation. +SEED = 20260909 +_VOCAB = ( + "cache prefix token restore restart segment manifest budget eviction " + "prefill decode latency throughput checkpoint durable radix tier admission " + "pipeline stream verify digest commit quarantine pin lease reserve node " + "mesh relay model runtime kernel attention matrix layer head batch queue " + "trace replay harness baseline cohort percentile regression gate promote" +).split() + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def stable_hash(value: Any) -> str: + return hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +# --------------------------------------------------------------------------- +# Manifest +# --------------------------------------------------------------------------- + + +class DeterministicRandom: + """Small LCG so manifests do not depend on the host Python random module.""" + + def __init__(self, seed: int) -> None: + self.state = seed & 0xFFFFFFFFFFFF + + def next(self) -> int: + self.state = (self.state * 25214903917 + 11) & 0xFFFFFFFFFFFF + return self.state >> 16 + + def below(self, bound: int) -> int: + return self.next() % bound + + def words(self, count: int) -> list[str]: + return [_VOCAB[self.below(len(_VOCAB))] for _ in range(count)] + + +def build_manifest(turns: int, turn_target_tokens: int, system_tokens: int) -> dict[str, Any]: + """Build the frozen conversation. Turn sizes are approximate (words * 4/3); + the authoritative prompt token counts come from server usage at run time.""" + + rng = DeterministicRandom(SEED) + + def block(target_tokens: int, topic: str) -> str: + words = max(1, int(target_tokens * 3 / 4)) + chunks = [] + while len(chunks) * 8 < words: + chunks.append(" ".join(rng.words(8))) + return f"[{topic}] " + " ".join(chunks) + + scaffold = block(system_tokens, "scaffold") + turn_specs = [] + for index in range(turns): + body = block(turn_target_tokens, f"turn-{index + 1}-context") + request = ( + f"Turn {index + 1}: given the project brief above, summarize the " + f"{' '.join(rng.words(6))} constraint in one sentence and list the " + f"{' '.join(rng.words(4))} next step." + ) + turn_specs.append({"context": body, "request": request}) + + return { + "schema_version": SCHEMA_VERSION, + "kind": "kv-restart-replay/manifest", + "seed": SEED, + "settings": { + "turns": turns, + "turn_target_tokens": turn_target_tokens, + "system_tokens": system_tokens, + "approx_total_prompt_tokens": system_tokens + turns * turn_target_tokens, + }, + "system": scaffold, + "turns": turn_specs, + } + + +# --------------------------------------------------------------------------- +# Server lifecycle (mirrors evals/agentic-replay.py) +# --------------------------------------------------------------------------- + + +def server_command(binary: Path, model: str, extra_args: Sequence[str]) -> list[str]: + command = [str(binary), "serve", "--model", model, "--log-format", "json"] + command.extend(extra_args) + for option in FORBIDDEN_STARTUP_OPTIONS: + if option in command: + raise AssertionError(f"default-startup benchmark cannot use {option}") + return command + + +def port_is_open(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool: + with socket.socket() as connection: + connection.settimeout(0.2) + return connection.connect_ex((host, port)) == 0 + + +def wait_for_model(timeout: float, process: subprocess.Popen[bytes]) -> str: + deadline = time.monotonic() + timeout + last_error = "not ready" + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError(f"Mesh exited before readiness with status {process.returncode}") + connection = http.client.HTTPConnection(DEFAULT_HOST, DEFAULT_PORT, timeout=5) + try: + connection.request("GET", "/v1/models") + response = connection.getresponse() + body = response.read() + if response.status == 200: + document = json.loads(body) + models = document.get("data") or [] + if models: + return models[0]["id"] + last_error = f"HTTP {response.status}: {body[:300]!r}" + except (OSError, json.JSONDecodeError) as error: + last_error = str(error) + finally: + connection.close() + time.sleep(1) + raise TimeoutError(f"Mesh did not become ready after {timeout}s: {last_error}") + + +def stop_server(process: subprocess.Popen[bytes]) -> None: + if process.poll() is None: + os.killpg(process.pid, signal.SIGINT) + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=10) + deadline = time.monotonic() + 10 + while port_is_open() and time.monotonic() < deadline: + time.sleep(0.2) + if port_is_open(): + raise RuntimeError("Mesh stopped but the serving port is still occupied") + + +def isolated_server_env(state_dir: Path) -> dict[str, str]: + env = os.environ.copy() + home = state_dir / "home" + home.mkdir(parents=True, exist_ok=True) + env.update( + { + "HOME": str(home), + "XDG_CACHE_HOME": str(state_dir / "xdg-cache"), + "XDG_CONFIG_HOME": str(state_dir / "xdg-config"), + "MESH_LLM_RUNTIME_ROOT": str(state_dir / "runtime"), + } + ) + if "HF_HOME" not in env: + env["HF_HOME"] = str(Path.home() / ".cache/huggingface") + return env + + +def start_server( + binary: Path, + model: str, + extra_args: Sequence[str], + state_dir: Path, + log_path: Path, +) -> tuple[subprocess.Popen[bytes], list[str]]: + if port_is_open(): + raise RuntimeError(f"TCP {DEFAULT_PORT} is already in use; stop the existing Mesh instance") + command = server_command(binary, model, extra_args) + log_path.parent.mkdir(parents=True, exist_ok=True) + log_handle = log_path.open("wb") + process = subprocess.Popen( + command, + cwd=str(REPO), + env=isolated_server_env(state_dir), + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + log_handle.close() + return process, command + + +# --------------------------------------------------------------------------- +# Requests (mirrors evals/agentic-replay.py stream_request) +# --------------------------------------------------------------------------- + + +def stream_request( + request_id: str, + messages: Sequence[dict[str, Any]], + model_id: str, + max_output_tokens: int, + timeout: float, +) -> dict[str, Any]: + started = time.monotonic() + first_token_at: Optional[float] = None + completion_tokens = 0 + prompt_tokens = 0 + cached_tokens = 0 + saw_done = False + connection = http.client.HTTPConnection(DEFAULT_HOST, DEFAULT_PORT, timeout=timeout) + payload = { + "model": model_id, + "messages": list(messages), + "max_tokens": max_output_tokens, + "temperature": 0, + "seed": 42, + "stream": True, + "stream_options": {"include_usage": True}, + } + try: + connection.request( + "POST", + "/v1/chat/completions", + json.dumps(payload), + {"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, + ) + response = connection.getresponse() + if response.status != 200: + body = response.read(4096).decode("utf-8", errors="replace") + return {"request_id": request_id, "error": f"HTTP {response.status}: {body}"} + for raw_line in response: + line = raw_line.strip() + if not line.startswith(b"data: "): + continue + event_bytes = line[6:] + if event_bytes == b"[DONE]": + saw_done = True + break + try: + event = json.loads(event_bytes) + except json.JSONDecodeError: + continue + server_error = event.get("error") + if server_error is not None: + return { + "request_id": request_id, + "error": f"stream failed with server error: {server_error}", + } + usage = event.get("usage") + if isinstance(usage, dict): + completion_tokens = int(usage.get("completion_tokens") or completion_tokens) + prompt_tokens = int(usage.get("prompt_tokens") or prompt_tokens) + details = usage.get("prompt_tokens_details") + if isinstance(details, dict): + cached_tokens = int(details.get("cached_tokens") or cached_tokens) + choices = event.get("choices") + if not isinstance(choices, list) or not choices: + continue + delta = choices[0].get("delta") if isinstance(choices[0], dict) else None + if isinstance(delta, dict) and delta.get("content") and first_token_at is None: + first_token_at = time.monotonic() + if first_token_at is None: + return {"request_id": request_id, "error": "stream completed without content tokens"} + if not saw_done and completion_tokens == 0: + return {"request_id": request_id, "error": "stream completed without completion-token usage"} + ended = time.monotonic() + return { + "request_id": request_id, + "ttft_seconds": first_token_at - started, + "total_seconds": ended - started, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "cached_tokens": cached_tokens, + "decode_tokens_per_second": ( + completion_tokens / (ended - first_token_at) if ended > first_token_at else None + ), + } + except (OSError, TimeoutError) as error: + return {"request_id": request_id, "error": str(error)} + finally: + connection.close() + + +# --------------------------------------------------------------------------- +# Provenance +# --------------------------------------------------------------------------- + + +def hardware_fingerprint() -> dict[str, Any]: + fingerprint: dict[str, Any] = { + "platform": sys.platform, + "python": sys.version.split()[0], + "hostname": socket.gethostname(), + } + if sys.platform == "darwin": + try: + fingerprint["chip"] = ( + subprocess.run( + ["sysctl", "-n", "machdep.cpu.brand_string"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + ) + fingerprint["machine_model"] = ( + subprocess.run( + ["sysctl", "-n", "hw.model"], capture_output=True, text=True, check=True + ).stdout.strip() + ) + except subprocess.CalledProcessError: + pass + try: + fingerprint["physical_memory_bytes"] = os.sysconf("HW_PHYSMEM") + except (ValueError, OSError): + fingerprint["physical_memory_bytes"] = None + fingerprint["cpu_core_count"] = os.cpu_count() + else: + fingerprint["cpu_core_count"] = os.cpu_count() + try: + meminfo = Path("/proc/meminfo").read_text(encoding="utf-8") + match = re.search(r"MemTotal:\s+(\d+)\s+kB", meminfo) + if match: + fingerprint["physical_memory_bytes"] = int(match.group(1)) * 1024 + except OSError: + pass + return fingerprint + + +def binary_provenance(binary: Path) -> dict[str, Any]: + provenance: dict[str, Any] = {"binary": str(binary), "binary_sha256": sha256_file(binary)} + try: + described = subprocess.run( + ["git", "describe", "--always", "--dirty", "--tags"], + cwd=str(REPO), + capture_output=True, + text=True, + check=True, + ) + provenance["git_describe"] = described.stdout.strip() + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=str(REPO), capture_output=True, text=True, check=True + ) + provenance["source_sha"] = commit.stdout.strip() + except subprocess.CalledProcessError: + provenance["git_describe"] = "unknown" + return provenance + + +# --------------------------------------------------------------------------- +# Cohorts and run +# --------------------------------------------------------------------------- + + +def summarize_cohort(name: str, rows: Sequence[dict[str, Any]]) -> dict[str, Any]: + successful = [row for row in rows if "error" not in row] + failed = [row for row in rows if "error" in row] + ttft = [row["ttft_seconds"] for row in successful] + + def percentile(values: Sequence[float], fraction: float) -> Optional[float]: + if not values: + return None + ordered = sorted(values) + index = min(math.ceil(len(ordered) * fraction) - 1, len(ordered) - 1) + return ordered[max(index, 0)] + + prompt_tokens = sum(row.get("prompt_tokens", 0) for row in successful) + cached_tokens = sum(row.get("cached_tokens", 0) for row in successful) + decode = [row["decode_tokens_per_second"] for row in successful if row.get("decode_tokens_per_second")] + return { + "cohort": name, + "requests": len(rows), + "failed": len(failed), + "ttft_p50_seconds": percentile(ttft, 0.50), + "ttft_p95_seconds": percentile(ttft, 0.95), + "total_seconds_mean": statistics.fmean(row["total_seconds"] for row in successful) if successful else None, + "prompt_tokens": prompt_tokens, + "cached_tokens": cached_tokens, + "cache_pct": (100 * cached_tokens / prompt_tokens) if prompt_tokens else None, + "decode_tokens_per_second_mean": statistics.fmean(decode) if decode else None, + } + + +def messages_through(messages: list[dict[str, Any]], turn_index: int) -> list[dict[str, Any]]: + """Conversation prefix up to and including turn ``turn_index`` (0-based).""" + return messages[: 2 * (turn_index + 1) + 1] + + +def run_arm(args: argparse.Namespace, output: Path) -> dict[str, Any]: + binary = Path(args.binary).resolve() + model_path = Path(args.model).resolve() + if not binary.exists(): + raise FileNotFoundError(f"binary not found: {binary}") + if not model_path.exists(): + raise FileNotFoundError(f"model not found: {model_path}") + + manifest = build_manifest(args.turns, args.turn_target_tokens, args.system_tokens) + manifest_sha = stable_hash(manifest) + conversation: list[dict[str, Any]] = [{"role": "system", "content": manifest["system"]}] + for spec in manifest["turns"]: + conversation.append({"role": "user", "content": f"{spec['context']}\n\n{spec['request']}"}) + conversation.append({"role": "assistant", "content": spec["request"]}) + + state_dir = (output / "server-state").resolve() + state_dir.mkdir(parents=True, exist_ok=True) + requests_path = output / "requests.jsonl" + rows: list[dict[str, Any]] = [] + + def record(cohort: str, index: int, result: dict[str, Any]) -> None: + row = {"cohort": cohort, "request_index": index, **result} + rows.append(row) + with requests_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(row, sort_keys=True) + "\n") + + def replay_frozen(cohort: str) -> None: + connection = http.client.HTTPConnection(DEFAULT_HOST, DEFAULT_PORT, timeout=5) + try: + connection.request("GET", "/v1/models") + document = json.loads(connection.getresponse().read()) + model_id = (document.get("data") or [{}])[0].get("id", "default") + finally: + connection.close() + for repeat in range(args.restore_repeats): + result = stream_request( + f"{cohort}-{repeat + 1}", + conversation, + model_id, + args.max_output_tokens, + args.request_timeout, + ) + record(cohort, repeat, result) + + provenance = { + "schema_version": SCHEMA_VERSION, + "kind": "kv-restart-replay/run", + "started_at": utc_now(), + "binary": binary_provenance(binary), + "model": { + "path": str(model_path), + "sha256": sha256_file(model_path), + "size_bytes": model_path.stat().st_size, + }, + "hardware": hardware_fingerprint(), + "config": { + "base_url": DEFAULT_BASE_URL, + "turns": args.turns, + "turn_target_tokens": args.turn_target_tokens, + "system_tokens": args.system_tokens, + "restore_repeats": args.restore_repeats, + "max_output_tokens": args.max_output_tokens, + "request_timeout": args.request_timeout, + "serve_extra_args": list(args.serve_extra_args), + }, + "manifest_sha256": manifest_sha, + "manifest": manifest, + } + + process = None + try: + # Cohort: fill — cold server, conversation grows turn by turn. + process, command = start_server( + binary, str(model_path), args.serve_extra_args, state_dir, output / "logs" / "fill.log" + ) + provenance["serve_command"] = command + model_id = wait_for_model(args.ready_timeout, process) + for index in range(args.turns): + result = stream_request( + f"fill-{index + 1}", + messages_through(conversation, index), + model_id, + args.max_output_tokens, + args.request_timeout, + ) + record("fill", index, result) + + # Cohort: restore — full process restart on the same state directory. + stop_server(process) + process = None + stopped_at = time.monotonic() + process, _ = start_server( + binary, str(model_path), args.serve_extra_args, state_dir, output / "logs" / "restore.log" + ) + wait_for_model(args.ready_timeout, process) + restart_gap_seconds = time.monotonic() - stopped_at + provenance["restart"] = { + "method": "SIGINT to the serving process group, then fresh start on the same state directory", + "restart_to_ready_seconds": restart_gap_seconds, + } + replay_frozen("restore") + + # Cohort: warm — repeat replays without restart. + replay_frozen("warm") + finally: + if process is not None: + try: + stop_server(process) + except RuntimeError: + pass + + provenance["cohorts"] = [ + summarize_cohort("fill", [row for row in rows if row["cohort"] == "fill"]), + summarize_cohort("restore", [row for row in rows if row["cohort"] == "restore"]), + summarize_cohort("warm", [row for row in rows if row["cohort"] == "warm"]), + ] + provenance["completed_at"] = utc_now() + return provenance + + +def write_report(run: dict[str, Any], path: Path) -> None: + lines = [ + "# KV restart replay", + "", + f"- source: `{run['binary'].get('source_sha', 'unknown')}` (`{run['binary'].get('git_describe', '?')}`)", + f"- model sha256: `{run['model']['sha256'][:16]}…`", + f"- manifest sha256: `{run['manifest_sha256'][:16]}…` ({run['config']['turns']} turns, " + f"≈{run['manifest']['settings']['approx_total_prompt_tokens']} prompt tokens)", + f"- serve extra args: `{run['config']['serve_extra_args'] or 'none'}`", + f"- restart-to-ready: {run.get('restart', {}).get('restart_to_ready_seconds', float('nan')):.1f}s", + "", + "| cohort | requests | failed | TTFT p50 (s) | TTFT p95 (s) | cached % | decode tok/s |", + "|---|---:|---:|---:|---:|---:|---:|", + ] + for cohort in run["cohorts"]: + fmt = lambda value: "—" if value is None else f"{value:.3f}" if isinstance(value, float) else value + lines.append( + f"| {cohort['cohort']} | {cohort['requests']} | {cohort['failed']} " + f"| {fmt(cohort['ttft_p50_seconds'])} | {fmt(cohort['ttft_p95_seconds'])} " + f"| {fmt(cohort['cache_pct'])} | {fmt(cohort['decode_tokens_per_second_mean'])} |" + ) + lines += [ + "", + "`restore` is the first-request-after-restart cohort; on a build without a", + "durable KV tier it is the cold-prefill reference. `warm` repeats the same", + "replay without restart (resident reuse reference).", + "", + ] + path.write_text("\n".join(lines), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", default=str(REPO / "target/release/mesh-llm")) + parser.add_argument("--model", required=True, help="path to a GGUF model file") + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--turns", type=int, default=4) + parser.add_argument("--turn-target-tokens", type=int, default=4750) + parser.add_argument("--system-tokens", type=int, default=500) + parser.add_argument("--restore-repeats", type=int, default=3) + parser.add_argument("--max-output-tokens", type=int, default=256) + parser.add_argument("--request-timeout", type=float, default=900.0) + parser.add_argument("--ready-timeout", type=float, default=900.0) + parser.add_argument( + "--serve-extra-args", + nargs=argparse.REMAINDER, + default=[], + help="explicit extra serving arguments (recorded verbatim in run.json)", + ) + args = parser.parse_args() + + output = args.output.resolve() + if (output / "run.json").exists(): + raise SystemExit(f"output already contains run.json: {output}") + output.mkdir(parents=True, exist_ok=True) + + run = run_arm(args, output) + write_json = output / "run.json" + write_json.write_text(json.dumps(run, indent=2, sort_keys=True) + "\n", encoding="utf-8") + write_report(run, output / "report.md") + print(f"wrote {write_json}") + for cohort in run["cohorts"]: + p50 = cohort["ttft_p50_seconds"] + print( + f" {cohort['cohort']:8s} p50={p50 if p50 is None else round(p50, 3)}s " + f"cache={cohort['cache_pct'] if cohort['cache_pct'] is None else round(cohort['cache_pct'], 1)}% " + f"failed={cohort['failed']}" + ) + return 0 if all(cohort["failed"] == 0 for cohort in run["cohorts"]) else 1 + + +if __name__ == "__main__": + sys.exit(main()) From fac7bd27a771f94b2a96d30cd2efdd943696423b Mon Sep 17 00:00:00 2001 From: jian yang <27684d585da499cbdba179b1747db3283a668f79e4e78e1399f080d87898627b@meshllm.communities.buzz.xyz> Date: Wed, 9 Sep 2026 15:41:08 +1000 Subject: [PATCH 03/41] evals: address review on KV restart replay harness - fill prefixes now end on the user turn being answered (CodeRabbit #454) - restore cohort records only the first post-restart replay; subsequent replays are resident-warm and recorded under the warm cohort (#499) - forbidden-startup-options check also rejects --opt=value forms (#166) - stream failures degrade to per-request errors instead of aborting (#348) - missing git degrades provenance instead of aborting (#413) --- evals/kv-restart-replay.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/evals/kv-restart-replay.py b/evals/kv-restart-replay.py index db2652495d..0256b34056 100644 --- a/evals/kv-restart-replay.py +++ b/evals/kv-restart-replay.py @@ -161,9 +161,10 @@ def block(target_tokens: int, topic: str) -> str: def server_command(binary: Path, model: str, extra_args: Sequence[str]) -> list[str]: command = [str(binary), "serve", "--model", model, "--log-format", "json"] command.extend(extra_args) - for option in FORBIDDEN_STARTUP_OPTIONS: - if option in command: - raise AssertionError(f"default-startup benchmark cannot use {option}") + for argument in command: + for option in FORBIDDEN_STARTUP_OPTIONS: + if argument == option or argument.startswith(f"{option}="): + raise AssertionError(f"default-startup benchmark cannot use {option}") return command @@ -344,7 +345,7 @@ def stream_request( completion_tokens / (ended - first_token_at) if ended > first_token_at else None ), } - except (OSError, TimeoutError) as error: + except (OSError, TimeoutError, http.client.HTTPException) as error: return {"request_id": request_id, "error": str(error)} finally: connection.close() @@ -410,7 +411,7 @@ def binary_provenance(binary: Path) -> dict[str, Any]: ["git", "rev-parse", "HEAD"], cwd=str(REPO), capture_output=True, text=True, check=True ) provenance["source_sha"] = commit.stdout.strip() - except subprocess.CalledProcessError: + except (subprocess.CalledProcessError, OSError): provenance["git_describe"] = "unknown" return provenance @@ -450,8 +451,13 @@ def percentile(values: Sequence[float], fraction: float) -> Optional[float]: def messages_through(messages: list[dict[str, Any]], turn_index: int) -> list[dict[str, Any]]: - """Conversation prefix up to and including turn ``turn_index`` (0-based).""" - return messages[: 2 * (turn_index + 1) + 1] + """Conversation prefix ending on the user message of ``turn_index`` (0-based). + + Fill requests must look like the requests a real session produces: the last + message is always the user turn being answered, never the canned assistant + reply that follows it in the canonical conversation. + """ + return messages[: 2 * (turn_index + 1)] def run_arm(args: argparse.Namespace, output: Path) -> dict[str, Any]: @@ -480,7 +486,7 @@ def record(cohort: str, index: int, result: dict[str, Any]) -> None: with requests_path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(row, sort_keys=True) + "\n") - def replay_frozen(cohort: str) -> None: + def replay_frozen(cohort: str, repeats: Optional[int] = None) -> None: connection = http.client.HTTPConnection(DEFAULT_HOST, DEFAULT_PORT, timeout=5) try: connection.request("GET", "/v1/models") @@ -488,7 +494,7 @@ def replay_frozen(cohort: str) -> None: model_id = (document.get("data") or [{}])[0].get("id", "default") finally: connection.close() - for repeat in range(args.restore_repeats): + for repeat in range(args.restore_repeats if repeats is None else repeats): result = stream_request( f"{cohort}-{repeat + 1}", conversation, @@ -554,7 +560,11 @@ def replay_frozen(cohort: str) -> None: "method": "SIGINT to the serving process group, then fresh start on the same state directory", "restart_to_ready_seconds": restart_gap_seconds, } - replay_frozen("restore") + # Cohort: restore — the FIRST post-restart replay alone is the + # first-request-after-restart measurement; later replays warm from the + # resident cache and are recorded under the warm cohort instead. + replay_frozen("restore", repeats=1) + replay_frozen("warm", repeats=max(args.restore_repeats - 1, 0)) # Cohort: warm — repeat replays without restart. replay_frozen("warm") From cb871b4a08934e9eb97fb5de489dda7fb9b8907e Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 10 Sep 2026 10:52:31 +1000 Subject: [PATCH 04/41] Spread concurrent new sessions with local route reservations (#1631) --- crates/mesh-client/src/network/affinity.rs | 2 + .../src/network/affinity.rs | 37 +- .../mesh-llm-host-runtime/src/network/mod.rs | 1 + .../src/network/openai/ingress.rs | 7 +- .../openai/moa_gateway/fleet_scale_tests.rs | 9 +- .../openai/moa_gateway/fleet_sim_tests.rs | 18 +- .../src/network/openai/moa_gateway/mod.rs | 27 +- .../src/network/openai/moa_gateway/pool.rs | 89 +---- .../network/openai/moa_gateway/self_fill.rs | 139 ++++++++ .../openai/moa_gateway/self_fill/tests.rs | 307 +++++++++++++++++ .../src/network/openai/moa_gateway/workers.rs | 28 +- .../src/network/openai/routing_rank.rs | 170 +++++++++- .../src/network/openai/transport.rs | 81 ++++- .../network/openai/transport_route_model.rs | 225 ++++++++++++- .../network/openai/transport_tests/routing.rs | 81 ++++- .../src/network/reservations.rs | 317 ++++++++++++++++++ .../src/runtime/proxy/tests/mod.rs | 53 ++- .../src/runtime/proxy/tests/routing.rs | 90 ++++- crates/mesh-llm-routing/src/affinity.rs | 18 + docs/design/MOA_GATEWAY.md | 13 +- 20 files changed, 1551 insertions(+), 161 deletions(-) create mode 100644 crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs create mode 100644 crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs create mode 100644 crates/mesh-llm-host-runtime/src/network/reservations.rs diff --git a/crates/mesh-client/src/network/affinity.rs b/crates/mesh-client/src/network/affinity.rs index f1b5456332..849f0205b3 100644 --- a/crates/mesh-client/src/network/affinity.rs +++ b/crates/mesh-client/src/network/affinity.rs @@ -154,11 +154,13 @@ mod tests { target: target.clone(), prefix_hash: None, cache_target: None, + affinity_applied: false, }; let prepared: PreparedTargets = PreparedTargets { ordered: vec![target], prefix_hash: selection.prefix_hash, cache_target: selection.cache_target, + affinity_applied: selection.affinity_applied, }; assert_eq!(prepared.ordered.len(), 1); } diff --git a/crates/mesh-llm-host-runtime/src/network/affinity.rs b/crates/mesh-llm-host-runtime/src/network/affinity.rs index 7b58c4a14c..0babd078c5 100644 --- a/crates/mesh-llm-host-runtime/src/network/affinity.rs +++ b/crates/mesh-llm-host-runtime/src/network/affinity.rs @@ -1,6 +1,7 @@ //! Prefix affinity and sticky routing helpers for inference target selection. use crate::inference::election; +use crate::network::reservations::{RoutingReservation, RoutingReservations}; use crate::network::target_health::{TargetHealth, TargetHealthOutcome, TargetReputationStats}; use iroh::EndpointId; use mesh_llm_routing::affinity as shared_affinity; @@ -38,10 +39,13 @@ pub struct AffinityStatsSnapshot { /// Legacy status compatibility paired with `learned`; permanently zero. pub evicted: u64, pub target_reputation: TargetReputationStats, + /// Requests currently holding an in-flight route reservation. + pub reservation_active: usize, } mesh_llm_routing::impl_affinity_stats_snapshot!(AffinityStatsSnapshot { target_reputation: TargetReputationStats::default(), + reservation_active: 0, }); #[derive(Clone, Debug)] @@ -76,6 +80,7 @@ pub struct AffinityRouter { inner: Arc>, prefix: Arc, target_health: TargetHealth, + reservations: RoutingReservations, } impl AffinityRouter { @@ -84,6 +89,7 @@ impl AffinityRouter { inner: Arc::new(Mutex::new(AffinityState::default())), prefix: Arc::new(shared_affinity::AffinityRouter::new()), target_health: TargetHealth::default(), + reservations: RoutingReservations::default(), } } @@ -96,6 +102,7 @@ impl AffinityRouter { sticky_enabled, )), target_health: TargetHealth::default(), + reservations: RoutingReservations::default(), } } @@ -106,6 +113,7 @@ impl AffinityRouter { self.prefix.sticky_enabled(), ); stats.target_reputation = self.target_health.reputation_stats(); + stats.reservation_active = self.reservations.active_total(); stats } @@ -139,6 +147,18 @@ impl AffinityRouter { self.target_health.record_outcome(model, target, outcome); } + pub(crate) fn reserve_route( + &self, + model: &str, + candidates: &[election::InferenceTarget], + spread_limit: usize, + preferred: &election::InferenceTarget, + affinity_applied: bool, + ) -> Option<(election::InferenceTarget, RoutingReservation)> { + self.reservations + .reserve(model, candidates, spread_limit, preferred, affinity_applied) + } + /// Look up a previously-classified model name for an auto-routed session. /// /// Auto routing classifies each request and picks a model. Without @@ -530,23 +550,19 @@ impl crate::mesh::Node { } } -/// Select an inference target for a model request from a caller-supplied candidate -/// list instead of pulling it from `targets`. This avoids cloning the entire -/// `ModelTargets` when the caller has already reordered the candidates (e.g. by -/// context capacity). -pub fn select_model_target_from_candidates( +/// Select from an already health-filtered snapshot. Callers that also reserve +/// or retry targets must use this same snapshot for those decisions. +pub(crate) fn select_model_target_from_eligible_candidates( targets: &election::ModelTargets, candidates: &[election::InferenceTarget], - model: &str, parsed_body: Option<&Value>, affinity: &AffinityRouter, cache_target: Option, ) -> TargetSelection { - let eligible_candidates = affinity.route_eligible_candidates(model, candidates); let routing = routing_keys(parsed_body); shared_affinity::select_model_target_from_keys( targets, - &eligible_candidates, + candidates, &routing, &affinity.prefix, cache_target, @@ -600,11 +616,13 @@ mod tests { target: target.clone(), prefix_hash: None, cache_target: None, + affinity_applied: false, }; let prepared: PreparedTargets = PreparedTargets { ordered: vec![target], prefix_hash: selection.prefix_hash, cache_target: selection.cache_target, + affinity_applied: selection.affinity_applied, }; assert_eq!(prepared.ordered.len(), 1); } @@ -787,10 +805,9 @@ mod tests { ); let candidates = targets.candidates("qwen"); let cached = election::InferenceTarget::Remote(id_b); - let selection = select_model_target_from_candidates( + let selection = select_model_target_from_eligible_candidates( &targets, &candidates, - "qwen", Some(&req_a), &affinity, Some(cached.clone()), diff --git a/crates/mesh-llm-host-runtime/src/network/mod.rs b/crates/mesh-llm-host-runtime/src/network/mod.rs index a8e8b0c4ea..c6e44409c1 100644 --- a/crates/mesh-llm-host-runtime/src/network/mod.rs +++ b/crates/mesh-llm-host-runtime/src/network/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod metrics; pub(crate) mod nostr; pub(crate) mod openai; pub(crate) mod proxy; +pub(crate) mod reservations; pub(crate) mod router; pub(crate) mod target_health; pub(crate) mod tunnel; diff --git a/crates/mesh-llm-host-runtime/src/network/openai/ingress.rs b/crates/mesh-llm-host-runtime/src/network/openai/ingress.rs index c522863e6d..4358b4df13 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/ingress.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/ingress.rs @@ -940,8 +940,11 @@ async fn try_handle_moa_intercept( tcp_stream, request, decision.effective_model.as_deref(), - Some(ctx.route.targets), - decision.required_tokens, + super::moa_gateway::MoaRoutingContext { + targets: Some(ctx.route.targets), + required_tokens: decision.required_tokens, + affinity: ctx.route.affinity, + }, route_observer, ) .await; diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_scale_tests.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_scale_tests.rs index 4967b2808f..80cdc5c194 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_scale_tests.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_scale_tests.rs @@ -39,7 +39,8 @@ async fn node_with_n_peers(models: &[FleetModel], count: u32) -> mesh::Node { async fn assemble(node: &mesh::Node) -> Vec { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); - let (_backends, models) = assemble_worker_pool(node, Some(&targets), Some(13_000), &http).await; + let (_backends, models) = + assemble_worker_pool(node, Some(&targets), Some(13_000), &http, None).await; models.into_iter().map(|m| m.name).collect() } @@ -92,7 +93,8 @@ async fn actor_ranking_is_bounded_at_thousand_scale() { let node = node_with_n_peers(&models, 3_000).await; let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); - let (_backends, pool) = assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + let (_backends, pool) = + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let started = Instant::now(); let actors = compute_actor_candidates(&node, &pool).await; @@ -211,7 +213,8 @@ async fn few_big_many_small_at_thousand_scale() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); - let (_b, pool) = assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + let (_b, pool) = + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &pool).await; let first = actors .first() diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs index 3d2abcca5b..7016478242 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs @@ -210,7 +210,7 @@ async fn admitted_pool(fleet: &[(FleetModel, usize)]) -> Vec { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; models.into_iter().map(|m| m.name).collect() } @@ -289,7 +289,7 @@ async fn bimodal_fleet_admission_and_actor() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &models).await; tracing::debug!("fleet nodes = {}", total_nodes(&fleet)); @@ -435,7 +435,7 @@ async fn healthy_small_model_precedes_deprioritized_big_actor() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &models).await; assert_eq!(models.len(), 3, "small spillover must remain admitted"); assert_eq!( @@ -484,7 +484,7 @@ async fn local_small_model_absorbs_load_when_big_models_are_deprioritized() { ); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &models).await; assert_eq!( @@ -589,7 +589,7 @@ async fn throughput_breaks_ties_between_healthy_same_tier_models() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &models).await; let ranked_bases = actors .iter() @@ -769,7 +769,7 @@ async fn tool_capability_outranks_health_for_the_acting_model() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &models).await; assert_eq!( super::pool::canonical_base_name(&models[actors[0]].name), @@ -812,7 +812,7 @@ async fn tool_capability_outranks_advertised_throughput_for_the_acting_model() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &models).await; assert_eq!( super::pool::canonical_base_name(&models[actors[0]].name), @@ -850,7 +850,7 @@ async fn actor_candidates_retain_every_admitted_worker_as_hedge_fallback() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let actors = compute_actor_candidates(&node, &models).await; assert_eq!( actors.len(), @@ -920,7 +920,7 @@ async fn committee_cap_keeps_a_healthy_worker_over_deprioritized_ones() { let targets = election::ModelTargets::default(); let http = reqwest::Client::new(); let (_backends, models) = - assemble_worker_pool(&node, Some(&targets), Some(13_000), &http).await; + assemble_worker_pool(&node, Some(&targets), Some(13_000), &http, None).await; let kept: Vec<&str> = models.iter().map(|m| m.name.as_str()).collect(); assert!( diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/mod.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/mod.rs index 69a149fec4..ca9daf337b 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/mod.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/mod.rs @@ -147,6 +147,13 @@ fn committee_admission( CommitteeAdmission::Admitted } +/// Per-request routing inputs shared with ordinary single-model routing. +pub struct MoaRoutingContext<'a> { + pub targets: Option<&'a election::ModelTargets>, + pub required_tokens: Option, + pub affinity: &'a crate::network::affinity::AffinityRouter, +} + /// Detect `model: "mesh"`, build a mesh-wide MoA config, run the turn, /// and write the HTTP response (JSON or SSE) directly to the stream. /// @@ -166,8 +173,7 @@ pub async fn try_handle_moa( tcp_stream: ClientStream, request: &mut proxy::BufferedHttpRequest, effective_model: Option<&str>, - targets: Option<&election::ModelTargets>, - required_tokens: Option, + routing: MoaRoutingContext<'_>, route_observer: OpenAiRouteObserver<'_>, ) -> MoaDispatchResult { if !effective_model.is_some_and(automatic::is_directive) { @@ -214,13 +220,20 @@ pub async fn try_handle_moa( let enable_thinking = effective_enable_thinking_for_moa(&body_json); - let Some(mut config) = admitted_gateway_config(node, targets, required_tokens).await else { + let Some(mut config) = admitted_gateway_config( + node, + routing.targets, + routing.required_tokens, + routing.affinity, + ) + .await + else { // Zero admitted workers cannot produce a turn, so degrade through the // ordinary selector (which returns 503 if no model exists). return degrade_to_single_model( node, - targets, - required_tokens, + routing.targets, + routing.required_tokens, tcp_stream, request, route_observer, @@ -242,6 +255,7 @@ pub async fn try_handle_moa( pub(in crate::network::openai) mod context_selection; mod pool; mod progress; +mod self_fill; mod streaming; mod workers; @@ -249,8 +263,9 @@ async fn admitted_gateway_config( node: &mesh::Node, targets: Option<&election::ModelTargets>, required_tokens: Option, + affinity: &crate::network::affinity::AffinityRouter, ) -> Option { - let config = build_moa_candidate_config(node, targets, required_tokens).await; + let config = build_moa_candidate_config(node, targets, required_tokens, affinity).await; let worker_count = config.models.len(); if gateway_required(worker_count) { return Some(config); diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/pool.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/pool.rs index ac0273fa1b..b5843afeb2 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/pool.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/pool.rs @@ -6,6 +6,7 @@ //! calls [`assemble_worker_pool`] and [`compute_actor_candidates`] here. use super::context_selection; +use super::self_fill::self_fill_from_extra_instances; use super::workers::{LocalModelBackend, RemoteModelBackend}; use crate::inference::election; use crate::mesh; @@ -325,6 +326,7 @@ pub(super) async fn assemble_worker_pool( targets: Option<&election::ModelTargets>, required_tokens: Option, http: &reqwest::Client, + affinity: Option<&crate::network::affinity::AffinityRouter>, ) -> ( Vec>, Vec, @@ -399,6 +401,7 @@ pub(super) async fn assemble_worker_pool( http, &mut backends, &mut models, + affinity, ) .await; } @@ -564,92 +567,6 @@ async fn cap_committee( *models = kept_models; } -/// Cap on same-model instances added by self-fill. Two is enough to switch a -/// single-model mesh from solo to a working committee; beyond that the extra -/// draft's marginal value falls and it is just latency/cost. -const SELF_FILL_TARGET_WORKERS: usize = 2; - -/// When only one model resolved, add extra reachable *nodes* serving that same -/// model as additional workers, up to [`SELF_FILL_TARGET_WORKERS`]. -/// -/// Only genuinely distinct remote endpoints are added — never the local backend -/// again and never the same peer twice — so each added worker is real capacity -/// from a node that joined the mesh. This is what makes a same-model mesh get -/// MoA at all; without it `build_moa_config` returns None for such a mesh. -async fn self_fill_from_extra_instances( - node: &mesh::Node, - targets: Option<&election::ModelTargets>, - required_tokens: Option, - http: &reqwest::Client, - backends: &mut Vec>, - models: &mut Vec, -) { - let Some(existing) = models.first().cloned() else { - return; - }; - let name = existing.name.clone(); - - // Rebuild the pool from DISTINCT physical endpoints serving this model: - // the local skippy port (if this node serves it and context fits) plus - // each distinct remote peer. `hosts_for_model` returns distinct peers, and - // the local endpoint is a different physical box from any of them, so no - // endpoint can appear twice. - // - // Iron law: a single physical endpoint must NEVER become a fake 2-worker - // committee. If fewer than two distinct endpoints serve the model, leave - // the pool as a genuine one-worker Mesh gateway. - let mut endpoints: Vec> = Vec::new(); - - if let Some(port) = targets.and_then(|t| { - t.targets.get(&name).and_then(|tv| { - tv.iter().find_map(|t| match t { - election::InferenceTarget::Local(p) => Some(*p), - _ => None, - }) - }) - }) { - let context_length = node.local_model_context_length(&name).await; - if context_selection::context_can_satisfy(required_tokens, context_length) { - endpoints.push(std::sync::Arc::new(LocalModelBackend { - port, - http: http.clone(), - })); - } - } - - for peer_id in node.hosts_for_model(&name).await { - if endpoints.len() >= SELF_FILL_TARGET_WORKERS { - break; - } - endpoints.push(std::sync::Arc::new(RemoteModelBackend { - node: node.clone(), - // Self-fill deliberately represents each replica as an independent - // sampled worker. Do not let one slot fail over onto another slot - // and duplicate that replica's answer. - peer_ids: vec![peer_id], - })); - } - - if endpoints.len() < 2 { - return; // single physical endpoint -> stay single-model (iron law) - } - endpoints.truncate(SELF_FILL_TARGET_WORKERS); - - tracing::info!( - "MoA: self-fill formed a {}-worker committee for {name} from distinct endpoints", - endpoints.len() - ); - *backends = endpoints; - // Every entry is the same model on a different endpoint, so they all carry - // the size the original entry resolved. - *models = (0..backends.len()) - .map(|i| moa::ModelEntry { - backend_index: i, - ..existing.clone() - }) - .collect(); -} - /// Drop small-tier workers when any big-tier worker is present. /// /// A weak draft can contaminate synthesis, and aggregation quality tracks diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs new file mode 100644 index 0000000000..f94c01332c --- /dev/null +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs @@ -0,0 +1,139 @@ +//! Same-model committees: distinct physical clones, reserved for the turn. +use super::workers::{LocalModelBackend, RemoteModelBackend, ReservedModelBackend}; +use crate::inference::election::{InferenceTarget, ModelTargets}; +use crate::mesh; +use crate::network::affinity::AffinityRouter; +use crate::network::openai::routing_rank::rank_targets_by_context; +use crate::network::reservations::RoutingReservation; +use mesh_mixture_of_agents as moa; +use std::sync::Arc; + +/// Measured self-MoA width; fleet capacity must not increase fan-out cost. +const SELF_FILL_TARGET_WORKERS: usize = 2; + +async fn select_clones( + node: &mesh::Node, + name: &str, + required_tokens: Option, + candidates: Vec, + affinity: Option<&AffinityRouter>, +) -> Vec<(InferenceTarget, Option)> { + use crate::proto::node::InferenceAdmissionState; + + let deprioritized: std::collections::HashSet<_> = node + .peers() + .await + .into_iter() + .filter(|peer| { + peer.inference_admission_state == Some(InferenceAdmissionState::AcceptingDeprioritized) + }) + .map(|peer| peer.id) + .collect(); + // Preserve admission priority before context/throughput ranking. Local and + // legacy peers stay healthy; hosts_for_model already excludes paused peers. + let (mut healthy, mut spillover): (Vec<_>, Vec<_>) = candidates.into_iter().partition( + |target| !matches!(target, InferenceTarget::Remote(id) if deprioritized.contains(id)), + ); + let mut selected = Vec::with_capacity(SELF_FILL_TARGET_WORKERS); + while selected.len() < SELF_FILL_TARGET_WORKERS { + // Exhaust context-eligible healthy endpoints before considering spillover, + // even when every healthy clone already has reservations from other turns. + let mut ranked = rank_targets_by_context(node, name, required_tokens, &healthy).await; + if ranked.ordered.is_empty() { + ranked = rank_targets_by_context(node, name, required_tokens, &spillover).await; + } + let Some(preferred) = ranked.ordered.first() else { + break; + }; + let (target, reservation) = affinity + .and_then(|router| { + router.reserve_route( + name, + &ranked.ordered, + ranked.equivalent_prefix, + preferred, + false, + ) + }) + .map(|(target, guard)| (target, Some(guard))) + .unwrap_or_else(|| (preferred.clone(), None)); + // Selection+reservation is atomic per slot; removing the endpoint + // prevents duplicate workers even when other turns interleave slots. + healthy.retain(|candidate| candidate != &target); + spillover.retain(|candidate| candidate != &target); + selected.push((target, reservation)); + } + selected +} + +pub(super) async fn self_fill_from_extra_instances( + node: &mesh::Node, + targets: Option<&ModelTargets>, + required_tokens: Option, + http: &reqwest::Client, + backends: &mut Vec>, + models: &mut Vec, + affinity: Option<&AffinityRouter>, +) { + let Some(existing) = models.first().cloned() else { + return; + }; + let name = &existing.name; + let mut candidates = Vec::new(); + if let Some(local) = targets + .and_then(|targets| targets.targets.get(name)) + .and_then(|targets| { + targets + .iter() + .find(|t| matches!(t, InferenceTarget::Local(_))) + }) + { + candidates.push(local.clone()); + } + candidates.extend( + node.hosts_for_model(name) + .await + .into_iter() + .map(InferenceTarget::Remote), + ); + if candidates.len() < 2 { + return; + } + let selected = select_clones(node, name, required_tokens, candidates, affinity).await; + if selected.len() < 2 { + return; // Context filtering must not fabricate a second worker. + } + *backends = selected + .into_iter() + .map(|(target, reservation)| { + let inner: Arc = match target { + InferenceTarget::Local(port) => Arc::new(LocalModelBackend { + port, + http: http.clone(), + }), + // No failover onto a sibling slot: every worker is a distinct sample. + InferenceTarget::Remote(peer_id) => Arc::new(RemoteModelBackend { + node: node.clone(), + peer_ids: vec![peer_id], + }), + InferenceTarget::None => unreachable!("self-fill only collects physical endpoints"), + }; + match reservation { + Some(reservation) => Arc::new(ReservedModelBackend { + inner, + _reservation: reservation, + }) as Arc, + None => inner, + } + }) + .collect(); + *models = (0..backends.len()) + .map(|backend_index| moa::ModelEntry { + backend_index, + ..existing.clone() + }) + .collect(); +} + +#[cfg(test)] +mod tests; diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs new file mode 100644 index 0000000000..01203d60f8 --- /dev/null +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs @@ -0,0 +1,307 @@ +use super::super::fleet_sim_tests::{BIG_MODELS, fleet_peer_with_health}; +use super::super::pool::assemble_worker_pool; +use super::*; +use std::collections::HashSet; +use tokio::sync::{Barrier, mpsc}; + +async fn fleet(count: u32) -> (mesh::Node, Vec) { + let node = mesh::Node::new_for_tests(mesh::NodeRole::Client) + .await + .unwrap(); + let mut candidates = Vec::new(); + for seed in 1..=count { + let peer = fleet_peer_with_health(seed, BIG_MODELS[0], None, Some(100_000)); + candidates.push(InferenceTarget::Remote(peer.id)); + node.insert_test_peer(peer).await; + } + (node, candidates) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_committees_spread_across_twenty_clones() { + let (node, candidates) = fleet(20).await; + let affinity = Arc::new(AffinityRouter::new()); + let start = Arc::new(Barrier::new(10)); + let mut tasks = Vec::new(); + for _ in 0..10 { + let (node, candidates, affinity, start) = ( + node.clone(), + candidates.clone(), + affinity.clone(), + start.clone(), + ); + tasks.push(tokio::spawn(async move { + start.wait().await; + select_clones( + &node, + BIG_MODELS[0].name, + Some(13_000), + candidates, + Some(&affinity), + ) + .await + })); + } + // JoinHandle outputs retain every guard until all selections have finished. + let mut committees = Vec::new(); + for task in tasks { + committees.push(task.await.unwrap()); + } + let mut unique = HashSet::new(); + for committee in &committees { + assert_eq!(committee.len(), 2); + assert_ne!(committee[0].0, committee[1].0); + for (target, _) in committee { + assert!( + unique.insert(format!("{target:?}")), + "clone reused while idle equivalents remain" + ); + } + } + assert_eq!(unique.len(), 20); + assert_eq!(affinity.stats_snapshot().reservation_active, 20); + drop(committees); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + +#[tokio::test] +async fn pressure_does_not_promote_slow_or_unknown_context_clones() { + let (node, mut candidates) = fleet(2).await; + let slow = fleet_peer_with_health(3, BIG_MODELS[0], None, Some(1_000)); + candidates.push(InferenceTarget::Remote(slow.id)); + node.insert_test_peer(slow).await; + let mut unknown = fleet_peer_with_health(4, BIG_MODELS[0], None, Some(100_000)); + unknown.served_model_runtime.clear(); + unknown.served_model_descriptors[0].metadata = None; + candidates.push(InferenceTarget::Remote(unknown.id)); + node.insert_test_peer(unknown).await; + let affinity = AffinityRouter::new(); + let mut committees = Vec::new(); + for _ in 0..5 { + let selected = select_clones( + &node, + BIG_MODELS[0].name, + Some(13_000), + candidates.clone(), + Some(&affinity), + ) + .await; + assert_eq!(selected.len(), 2); + assert!( + selected + .iter() + .all(|(target, _)| candidates[..2].contains(target)) + ); + committees.push(selected); + } + assert_eq!(affinity.stats_snapshot().reservation_active, 10); + drop(committees); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + +#[tokio::test] +async fn cancelling_assembled_turn_releases_backend_reservations() { + let (node, _) = fleet(20).await; + let affinity = Arc::new(AffinityRouter::new()); + let (ready_tx, mut ready_rx) = mpsc::channel(1); + let task_affinity = affinity.clone(); + let task = tokio::spawn(async move { + let (backends, models) = assemble_worker_pool( + &node, + None, + Some(13_000), + &reqwest::Client::new(), + Some(&task_affinity), + ) + .await; + assert_eq!(models.len(), 2); + // The real gateway clones backend Arcs for worker/reducer calls. + let worker_reference = backends[0].clone(); + drop(backends); + assert_eq!(task_affinity.stats_snapshot().reservation_active, 1); + ready_tx.send(()).await.unwrap(); + std::future::pending::<()>().await; + drop(worker_reference); + }); + tokio::time::timeout(std::time::Duration::from_secs(10), ready_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(affinity.stats_snapshot().reservation_active, 1); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + +#[tokio::test] +async fn singleton_and_context_filtered_fleet_do_not_leak_reservations() { + let (node, candidates) = fleet(1).await; + let affinity = AffinityRouter::new(); + let (backends, models) = assemble_worker_pool( + &node, + None, + Some(13_000), + &reqwest::Client::new(), + Some(&affinity), + ) + .await; + assert_eq!(models.len(), 1); + drop(backends); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); + let selected = select_clones( + &node, + BIG_MODELS[0].name, + Some(100_000), + candidates, + Some(&affinity), + ) + .await; + assert!(selected.is_empty()); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + +#[tokio::test] +async fn healthy_clones_beat_faster_deprioritized_clone_even_under_pressure() { + use crate::proto::node::InferenceAdmissionState; + + let (node, healthy) = fleet(2).await; + let hot = fleet_peer_with_health( + 3, + BIG_MODELS[0], + Some(InferenceAdmissionState::AcceptingDeprioritized), + Some(200_000), + ); + node.insert_test_peer(hot).await; + let candidates: Vec<_> = node + .hosts_for_model(BIG_MODELS[0].name) + .await + .into_iter() + .map(InferenceTarget::Remote) + .collect(); + let affinity = AffinityRouter::new(); + let mut committees = Vec::new(); + // Also preserve health priority when reservation accounting is disabled. + for router in [None, Some(&affinity), Some(&affinity), Some(&affinity)] { + let selected = select_clones( + &node, + BIG_MODELS[0].name, + Some(13_000), + candidates.clone(), + router, + ) + .await; + assert_eq!(selected.len(), 2); + assert_ne!(selected[0].0, selected[1].0); + assert!(selected.iter().all(|(target, _)| healthy.contains(target))); + committees.push(selected); + } + assert_eq!(affinity.stats_snapshot().reservation_active, 6); + drop(committees); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + +#[tokio::test] +async fn depleted_healthy_tier_uses_and_spreads_spillover() { + use crate::proto::node::InferenceAdmissionState; + + // With one healthy clone it owns slot one; with none, both slots may + // use spillover. Equal throughput across tiers must not merge their + // reservation windows, even after the healthy clone is reserved. + for healthy_count in [0, 1] { + let (node, healthy) = fleet(healthy_count).await; + let mut spillover = Vec::new(); + for seed in 2..=5 { + let peer = fleet_peer_with_health( + seed, + BIG_MODELS[0], + Some(InferenceAdmissionState::AcceptingDeprioritized), + Some(100_000), + ); + spillover.push(InferenceTarget::Remote(peer.id)); + node.insert_test_peer(peer).await; + } + let candidates = node + .hosts_for_model(BIG_MODELS[0].name) + .await + .into_iter() + .map(InferenceTarget::Remote) + .collect::>(); + let affinity = AffinityRouter::new(); + let mut committees = Vec::new(); + let mut used_spillover = HashSet::new(); + for _ in 0..(4 / (2 - healthy_count)) { + let selected = select_clones( + &node, + BIG_MODELS[0].name, + Some(13_000), + candidates.clone(), + Some(&affinity), + ) + .await; + assert_eq!(selected.len(), 2); + assert_ne!(selected[0].0, selected[1].0); + if healthy_count == 1 { + assert_eq!(selected[0].0, healthy[0]); + } + for (target, _) in &selected { + if spillover.contains(target) { + assert!(used_spillover.insert(format!("{target:?}"))); + } + } + committees.push(selected); + } + assert_eq!(used_spillover.len(), 4); + assert_eq!( + affinity.stats_snapshot().reservation_active, + committees.len() * 2 + ); + drop(committees); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); + } +} + +#[tokio::test] +async fn context_ineligible_healthy_clones_do_not_block_spillover() { + use crate::proto::node::InferenceAdmissionState; + + let (node, _) = fleet(2).await; + let mut spillover = Vec::new(); + for seed in 3..=4 { + let mut peer = fleet_peer_with_health( + seed, + BIG_MODELS[0], + Some(InferenceAdmissionState::AcceptingDeprioritized), + Some(200_000), + ); + // Existing healthy clones cannot fit 100k; these can. + for runtime in &mut peer.served_model_runtime { + runtime.context_length = Some(200_000); + } + spillover.push(InferenceTarget::Remote(peer.id)); + node.insert_test_peer(peer).await; + } + let candidates = node + .hosts_for_model(BIG_MODELS[0].name) + .await + .into_iter() + .map(InferenceTarget::Remote) + .collect(); + let affinity = AffinityRouter::new(); + let selected = select_clones( + &node, + BIG_MODELS[0].name, + Some(100_000), + candidates, + Some(&affinity), + ) + .await; + assert_eq!(selected.len(), 2); + assert_ne!(selected[0].0, selected[1].0); + assert!( + selected + .iter() + .all(|(target, _)| spillover.contains(target)) + ); + drop(selected); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/workers.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/workers.rs index ebced31e6b..7e1a170c73 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/workers.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/workers.rs @@ -117,9 +117,11 @@ pub(super) async fn build_moa_candidate_config( node: &mesh::Node, targets: Option<&election::ModelTargets>, required_tokens: Option, + affinity: &crate::network::affinity::AffinityRouter, ) -> moa::GatewayConfig { let http = reqwest::Client::new(); - let (backends, models) = assemble_worker_pool(node, targets, required_tokens, &http).await; + let (backends, models) = + assemble_worker_pool(node, targets, required_tokens, &http, Some(affinity)).await; // Actor priority for the asymmetric tool path: best tool-caller first. // The actor is the one model that actually emits the tool call, so it must @@ -242,6 +244,30 @@ fn patience_profile(public_mesh: bool) -> PatienceProfile { } } +/// Keeps a physical clone reserved while any turn/backend reference survives. +/// Self-fill slots are pinned: failure must not retry onto another slot's clone. +pub(super) struct ReservedModelBackend { + pub(super) inner: std::sync::Arc, + pub(super) _reservation: crate::network::reservations::RoutingReservation, +} + +#[async_trait::async_trait] +impl moa::ModelBackend for ReservedModelBackend { + async fn chat_completion( + &self, + model: &str, + messages: &[serde_json::Value], + tools: Option<&serde_json::Value>, + max_tokens: u32, + timeout: std::time::Duration, + sampling: moa::SamplingParams, + ) -> Result { + self.inner + .chat_completion(model, messages, tools, max_tokens, timeout, sampling) + .await + } +} + /// Backend that calls a local model directly on its skippy HTTP port. pub(super) struct LocalModelBackend { pub(super) port: u16, diff --git a/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs b/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs index 7bbe8beaa7..952cf0603d 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs @@ -83,7 +83,12 @@ struct RankedTarget { const LOCAL_THROUGHPUT_PRECEDENCE_SAMPLES: u64 = 3; const TARGET_THROUGHPUT_MAX_SCORE_SAMPLES: u64 = 32; -fn target_throughput_rank_key(throughput: Option) -> (bool, bool, u64, u64) { +/// Sort key produced by [`target_throughput_rank_key`]. Candidates with equal +/// keys are throughput-equivalent: the measurements give no reason to prefer +/// one over the other. +pub(super) type ThroughputRankKey = (bool, bool, u64, u64); + +fn target_throughput_rank_key(throughput: Option) -> ThroughputRankKey { let Some(throughput) = throughput else { return (false, false, 0, 0); }; @@ -109,10 +114,33 @@ fn sort_ranked_targets(targets: &mut [RankedTarget]) { }); } -pub(super) fn reorder_candidates_by_context_and_throughput( +/// Candidates ordered by context fit and measured throughput, plus the length +/// of the leading run of targets whose throughput rank ties with the best one. +/// +/// `equivalent_prefix` bounds in-flight reservation spreading: only the +/// leading targets that the measurements cannot distinguish are equivalent +/// enough to trade off by local in-flight count. It never crosses the +/// adequate-context / unknown-context boundary. +pub(super) struct RankedCandidates { + pub(super) ordered: Vec, + pub(super) equivalent_prefix: usize, +} + +fn equivalent_prefix_len(targets: &[RankedTarget]) -> usize { + let Some(first) = targets.first() else { + return 0; + }; + let key = target_throughput_rank_key(first.throughput); + targets + .iter() + .take_while(|target| target_throughput_rank_key(target.throughput) == key) + .count() +} + +pub(super) fn rank_candidates_by_context_and_throughput( candidates: &[(T, Option, Option)], required_tokens: Option, -) -> Vec { +) -> RankedCandidates { let ranked = candidates .iter() .enumerate() @@ -129,7 +157,11 @@ pub(super) fn reorder_candidates_by_context_and_throughput( let Some(required_tokens) = required_tokens else { let mut ranked = ranked; sort_ranked_targets(&mut ranked); - return ranked.into_iter().map(|ranked| ranked.candidate).collect(); + let equivalent_prefix = equivalent_prefix_len(&ranked); + return RankedCandidates { + ordered: ranked.into_iter().map(|ranked| ranked.candidate).collect(), + equivalent_prefix, + }; }; let mut adequate = Vec::new(); @@ -143,16 +175,35 @@ pub(super) fn reorder_candidates_by_context_and_throughput( } if adequate.is_empty() && unknown.is_empty() { - return Vec::new(); + return RankedCandidates { + ordered: Vec::new(), + equivalent_prefix: 0, + }; } sort_ranked_targets(&mut adequate); sort_ranked_targets(&mut unknown); - adequate - .into_iter() - .chain(unknown) - .map(|ranked| ranked.candidate) - .collect() + let equivalent_prefix = if adequate.is_empty() { + equivalent_prefix_len(&unknown) + } else { + equivalent_prefix_len(&adequate) + }; + RankedCandidates { + ordered: adequate + .into_iter() + .chain(unknown) + .map(|ranked| ranked.candidate) + .collect(), + equivalent_prefix, + } +} + +#[cfg(test)] +fn reorder_candidates_by_context_and_throughput( + candidates: &[(T, Option, Option)], + required_tokens: Option, +) -> Vec { + rank_candidates_by_context_and_throughput(candidates, required_tokens).ordered } fn local_target_throughput_rank( @@ -203,12 +254,12 @@ async fn remote_target_throughput_rank( gossiped.or(local) } -pub(super) async fn order_remote_hosts_by_context( +pub(super) async fn rank_remote_hosts_by_context( node: &mesh::Node, model: &str, required_tokens: Option, hosts: &[iroh::EndpointId], -) -> Vec { +) -> RankedCandidates { let mut candidates = Vec::with_capacity(hosts.len()); for host in hosts { candidates.push(( @@ -217,15 +268,27 @@ pub(super) async fn order_remote_hosts_by_context( remote_target_throughput_rank(node, model, *host).await, )); } - reorder_candidates_by_context_and_throughput(&candidates, required_tokens) + rank_candidates_by_context_and_throughput(&candidates, required_tokens) } -pub(super) async fn order_targets_by_context( +#[cfg(test)] +pub(super) async fn order_remote_hosts_by_context( + node: &mesh::Node, + model: &str, + required_tokens: Option, + hosts: &[iroh::EndpointId], +) -> Vec { + rank_remote_hosts_by_context(node, model, required_tokens, hosts) + .await + .ordered +} + +pub(super) async fn rank_targets_by_context( node: &mesh::Node, model: &str, required_tokens: Option, targets: &[election::InferenceTarget], -) -> Vec { +) -> RankedCandidates { let mut candidates = Vec::with_capacity(targets.len()); for target in targets { let context_length = match target { @@ -243,7 +306,19 @@ pub(super) async fn order_targets_by_context( }; candidates.push((target.clone(), context_length, throughput)); } - reorder_candidates_by_context_and_throughput(&candidates, required_tokens) + rank_candidates_by_context_and_throughput(&candidates, required_tokens) +} + +#[cfg(test)] +pub(super) async fn order_targets_by_context( + node: &mesh::Node, + model: &str, + required_tokens: Option, + targets: &[election::InferenceTarget], +) -> Vec { + rank_targets_by_context(node, model, required_tokens, targets) + .await + .ordered } pub(super) fn move_target_first(targets: &mut [T], target: &T) -> bool { @@ -489,6 +564,69 @@ mod tests { assert_eq!(budget, prompt_tokens + request_token_margin(prompt_tokens)); } + fn throughput(milli: u64) -> Option { + Some(TargetThroughputRank { + avg_tokens_per_second_milli: milli, + throughput_samples: 4, + local_observation: false, + }) + } + + #[test] + fn test_equivalent_prefix_is_one_when_ranks_differ() { + let ranked = rank_candidates_by_context_and_throughput( + &[ + (1u8, Some(8192), throughput(40_000)), + (2u8, Some(8192), throughput(10_000)), + ], + Some(4096), + ); + + assert_eq!(ranked.ordered, vec![1, 2]); + assert_eq!(ranked.equivalent_prefix, 1); + } + + #[test] + fn test_equivalent_prefix_covers_tied_throughput_ranks() { + let ranked = rank_candidates_by_context_and_throughput( + &[ + (1u8, Some(8192), throughput(40_000)), + (2u8, Some(8192), throughput(40_000)), + (3u8, Some(8192), throughput(10_000)), + ], + Some(4096), + ); + + assert_eq!(ranked.ordered, vec![1, 2, 3]); + assert_eq!(ranked.equivalent_prefix, 2); + } + + #[test] + fn test_equivalent_prefix_covers_all_unmeasured_candidates() { + let ranked = rank_candidates_by_context_and_throughput( + &[ + (1u8, Some(8192), None), + (2u8, Some(8192), None), + (3u8, None, None), + ], + Some(4096), + ); + + assert_eq!(ranked.ordered, vec![1, 2, 3]); + assert_eq!(ranked.equivalent_prefix, 2); + } + + #[test] + fn test_equivalent_prefix_does_not_cross_into_unknown_context_tier() { + let ranked = rank_candidates_by_context_and_throughput( + &[(1u8, Some(8192), None), (2u8, None, None)], + Some(4096), + ); + + assert_eq!(ranked.ordered, vec![1, 2]); + assert_eq!(ranked.equivalent_prefix, 1); + } + #[test] fn test_reorder_candidates_by_context_prefers_known_fit_then_unknown() { let ordered = reorder_candidates_by_context_and_throughput( diff --git a/crates/mesh-llm-host-runtime/src/network/openai/transport.rs b/crates/mesh-llm-host-runtime/src/network/openai/transport.rs index ec3a5fa2e7..896b6e9e2f 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/transport.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/transport.rs @@ -39,9 +39,10 @@ use super::response::{ request_service_for_target, route_attempt_result_label, route_http_endpoint_attempt, route_local_attempt, route_remote_attempt, target_health_outcome_for_attempt, }; +#[cfg(test)] +use super::routing_rank::order_remote_hosts_by_context; use super::routing_rank::{ - cached_auto_model_satisfies_media_requirements, move_target_first, - order_remote_hosts_by_context, order_targets_by_context, + cached_auto_model_satisfies_media_requirements, move_target_first, rank_remote_hosts_by_context, }; use mesh_llm_events::logging::events::TokenUsage; use mesh_llm_events::logging::identifiers::RequestId; @@ -197,6 +198,10 @@ struct MeshRequestPlan { effective_model: Option, auto_session_key: Option, target_hosts: Vec, + /// Leading run of `target_hosts` whose throughput rank ties with the best + /// one; reservation spreading is confined to this run. + equivalent_hosts: usize, + affinity_applied: bool, } enum MeshRequestFailure { @@ -350,6 +355,7 @@ pub async fn handle_mesh_request( &node, tcp_stream, &mut request, + &affinity, lifecycle.route_observer(), ) .await @@ -415,6 +421,7 @@ async fn route_mesh_moa_or_passthrough( node: &mesh::Node, tcp_stream: ClientStream, request: &mut BufferedHttpRequest, + affinity: &AffinityRouter, route_observer: OpenAiRouteObserver<'_>, ) -> Result { if request.is_tokenize_request() { @@ -428,8 +435,11 @@ async fn route_mesh_moa_or_passthrough( tcp_stream, request, moa_model_name.as_deref(), - None, // passive path has no local targets table - moa_required_tokens, + super::moa_gateway::MoaRoutingContext { + targets: None, // passive path has no local targets table + required_tokens: moa_required_tokens, + affinity, + }, route_observer, ) .await @@ -521,7 +531,7 @@ async fn build_mesh_request_plan( &resolved_hosts, affinity, ); - let target_hosts = order_mesh_target_hosts( + let (target_hosts, equivalent_hosts) = order_mesh_target_hosts( node, effective_model.as_deref(), required_tokens, @@ -533,6 +543,8 @@ async fn build_mesh_request_plan( effective_model, auto_session_key, target_hosts, + equivalent_hosts, + affinity_applied: prepared.affinity_applied, }) } @@ -570,6 +582,7 @@ fn prepare_mesh_targets( .collect(), prefix_hash: None, cache_target: None, + affinity_applied: false, }) } @@ -579,7 +592,7 @@ async fn order_mesh_target_hosts( required_tokens: Option, prepared: &mut PreparedTargets, affinity: &AffinityRouter, -) -> Vec { +) -> (Vec, usize) { let target_hosts: Vec = prepared .ordered .iter() @@ -589,10 +602,12 @@ async fn order_mesh_target_hosts( }) .collect(); let Some(name) = effective_model else { - return target_hosts; + let hosts_len = target_hosts.len(); + return (target_hosts, hosts_len); }; - let mut ordered = - order_remote_hosts_by_context(node, name, required_tokens, &target_hosts).await; + let ranked = rank_remote_hosts_by_context(node, name, required_tokens, &target_hosts).await; + let equivalent_hosts = ranked.equivalent_prefix; + let mut ordered = ranked.ordered; if affinity.prefix_enabled() && let Some(prefix_hash) = prepared.prefix_hash { @@ -614,13 +629,17 @@ async fn order_mesh_target_hosts( selected } }; + prepared.affinity_applied |= prepared.cache_target.is_some(); affinity.record_cache_probe(prepared.cache_target.is_some()); if let Some(election::InferenceTarget::Remote(cache_host)) = prepared.cache_target.as_ref() { + // Cache affinity sets `affinity_applied`, which disables + // reservation spreading, so this rotation cannot leak a + // lower-ranked host into the equivalent run. move_target_first(&mut ordered, cache_host); } } - ordered + (ordered, equivalent_hosts) } async fn handle_mesh_request_failure( @@ -686,7 +705,7 @@ async fn route_mesh_request_attempts( ) -> MeshRouteResult { let effective_model = plan.effective_model.as_deref(); let auto_session_key = plan.auto_session_key; - let target_hosts = &plan.target_hosts; + let (target_hosts, mut reservation) = reserve_mesh_request_target(plan, affinity); let total_targets = target_hosts.len(); let mut state = MeshAttemptState { route_started: Instant::now(), @@ -695,6 +714,7 @@ async fn route_mesh_request_attempts( refreshed: false, }; for (idx, target_host) in target_hosts.iter().enumerate() { + transfer_mesh_reservation(reservation.as_mut(), *target_host); state.attempts += 1; let attempt_started = Instant::now(); let attempt_result = route_remote_attempt_with_retry( @@ -755,6 +775,45 @@ async fn route_mesh_request_attempts( MeshRouteResult::Exhausted(tcp_stream) } +fn reserve_mesh_request_target( + plan: &MeshRequestPlan, + affinity: &AffinityRouter, +) -> ( + Vec, + Option, +) { + let mut target_hosts = plan.target_hosts.clone(); + let reservation = plan.effective_model.as_deref().and_then(|model| { + let candidates = target_hosts + .iter() + .copied() + .map(election::InferenceTarget::Remote) + .collect::>(); + let preferred = candidates.first()?; + let (selected, reservation) = affinity.reserve_route( + model, + &candidates, + plan.equivalent_hosts, + preferred, + plan.affinity_applied, + )?; + if let election::InferenceTarget::Remote(selected_host) = selected { + move_target_first(&mut target_hosts, &selected_host); + } + Some(reservation) + }); + (target_hosts, reservation) +} + +fn transfer_mesh_reservation( + reservation: Option<&mut crate::network::reservations::RoutingReservation>, + target_host: iroh::EndpointId, +) { + if let Some(reservation) = reservation { + reservation.transfer_to(&election::InferenceTarget::Remote(target_host)); + } +} + fn finish_route_attempt( route_observer: OpenAiRouteObserver<'_>, attempt: Option, diff --git a/crates/mesh-llm-host-runtime/src/network/openai/transport_route_model.rs b/crates/mesh-llm-host-runtime/src/network/openai/transport_route_model.rs index 6ea498b213..adbdaae522 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/transport_route_model.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/transport_route_model.rs @@ -1,4 +1,6 @@ use super::*; +use crate::network::openai::routing_rank::{RankedCandidates, rank_targets_by_context}; +use crate::network::reservations::RoutingReservation; pub(crate) struct RouteModelRequestContext<'a> { pub(crate) required_tokens: Option, @@ -93,9 +95,9 @@ async fn route_model_request_inner(args: RouteModelRequestArgs<'_>) -> RouteDisp } = args; let route_started = Instant::now(); let mut tcp_stream = tcp_stream; - let ordered_candidates = - order_targets_by_context(&node, model, required_tokens, &targets.candidates(model)).await; - let ordered_candidates = affinity.route_eligible_candidates(model, &ordered_candidates); + let ranked = + rank_targets_by_context(&node, model, required_tokens, &targets.candidates(model)).await; + let ordered_candidates = affinity.route_eligible_candidates(model, &ranked.ordered); if ordered_candidates.is_empty() { record_route_model_unavailable(&node, model, 0); let reason = no_context_eligible_target_reason(model, required_tokens); @@ -109,19 +111,21 @@ async fn route_model_request_inner(args: RouteModelRequestArgs<'_>) -> RouteDisp let prefix_hash = crate::network::affinity::cache_prefix_hash(request.body_json.as_ref()); let cache_target = cache_target_for_request(&node, affinity, model, prefix_hash, &ordered_candidates).await; - let selection = crate::network::affinity::select_model_target_from_candidates( + let Some(ReservedModelRoute { + selection, + ordered, + mut reservation, + }) = select_and_reserve_model_route( targets, - &ordered_candidates, + &ranked, model, request.body_json.as_ref(), affinity, cache_target, - ); - if matches!(selection.target, election::InferenceTarget::None) { + ) + else { return send_route_model_none_target(&node, tcp_stream, model, route_observer).await; - } - let mut ordered = ordered_candidates; - move_target_first(&mut ordered, &selection.target); + }; let total_targets = ordered.len(); let mut state = RouteModelState { route_started, @@ -134,6 +138,7 @@ async fn route_model_request_inner(args: RouteModelRequestArgs<'_>) -> RouteDisp // the identical nonce instead of letting each target's frontend mint its own. let forwarding_raw = request.raw.as_slice(); for (idx, target) in ordered.into_iter().enumerate() { + reservation.transfer_to(&target); state.attempts += 1; let attempt_started = Instant::now(); let retry_policy = ResponseRetryPolicy::next_target_available(idx + 1 < total_targets); @@ -211,6 +216,58 @@ async fn route_model_request_inner(args: RouteModelRequestArgs<'_>) -> RouteDisp .await } +struct ReservedModelRoute { + selection: TargetSelection, + ordered: Vec, + reservation: RoutingReservation, +} + +fn select_and_reserve_model_route( + targets: &election::ModelTargets, + ranked: &RankedCandidates, + model: &str, + parsed_body: Option<&serde_json::Value>, + affinity: &AffinityRouter, + cache_target: Option, +) -> Option { + // Cache lookup can await while another request cools a target. Refresh + // health once here, then use exactly this snapshot for selection, + // reservation spreading, and retries. A second filter inside selection + // would let the reservation undo a health decision it never saw. + let mut ordered = affinity.route_eligible_candidates(model, &ranked.ordered); + let mut selection = crate::network::affinity::select_model_target_from_eligible_candidates( + targets, + &ordered, + parsed_body, + affinity, + cache_target, + ); + if matches!(selection.target, election::InferenceTarget::None) { + return None; + } + // Health policy can remove or reorder candidates, so the original + // equivalent run can shrink or disappear. Never let its old length admit + // a lower-ranked fallback. + let spread_limit = ordered + .iter() + .take_while(|candidate| ranked.ordered[..ranked.equivalent_prefix].contains(candidate)) + .count(); + let (target, reservation) = affinity.reserve_route( + model, + &ordered, + spread_limit, + &selection.target, + selection.affinity_applied, + )?; + selection.target = target; + move_target_first(&mut ordered, &selection.target); + Some(ReservedModelRoute { + selection, + ordered, + reservation, + }) +} + fn record_route_model_unavailable(node: &mesh::Node, model: &str, attempts: usize) { node.record_routed_request( Some(model), @@ -499,8 +556,155 @@ fn record_route_model_attempt( #[cfg(test)] mod tests { use super::*; + use crate::network::target_health::TargetHealthOutcome; use iroh::SecretKey; + #[test] + fn reservation_does_not_reintroduce_target_cooled_during_cache_lookup() { + let affinity = AffinityRouter::with_config(true, true); + let first = election::InferenceTarget::Local(9001); + let second = election::InferenceTarget::Local(9002); + let ranked = RankedCandidates { + ordered: vec![first.clone(), second.clone()], + equivalent_prefix: 2, + }; + let (_, pressure) = affinity + .reserve_route("qwen", &ranked.ordered, 2, &second, true) + .unwrap(); + // This is the snapshot passed to the asynchronous cache lookup. + let before_lookup = affinity.route_eligible_candidates("qwen", &ranked.ordered); + assert_eq!(before_lookup, ranked.ordered); + // Another request times out while that lookup is suspended. + affinity.record_target_outcome(Some("qwen"), &first, TargetHealthOutcome::Timeout); + for cache_target in [None, Some(first.clone())] { + let route = select_and_reserve_model_route( + &election::ModelTargets::default(), + &ranked, + "qwen", + None, + &affinity, + cache_target, + ) + .unwrap(); + assert_eq!(route.selection.target, second); + assert_eq!(route.ordered, vec![second.clone()]); + assert!(!route.selection.affinity_applied); + assert_eq!(affinity.stats_snapshot().reservation_active, 2); + } + drop(pressure); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); + } + + #[test] + fn refreshed_spread_window_does_not_admit_lower_ranked_fallback() { + let first = election::InferenceTarget::Local(9001); + let second = election::InferenceTarget::Local(9002); + let fallback = election::InferenceTarget::Local(9003); + // Cover both a shortened equivalent run and one removed entirely. + for equivalent_prefix in [2, 1] { + let affinity = AffinityRouter::new(); + let ranked = RankedCandidates { + ordered: vec![first.clone(), second.clone(), fallback.clone()], + equivalent_prefix, + }; + let (_, _pressure) = affinity + .reserve_route("qwen", &ranked.ordered, 3, &second, true) + .unwrap(); + affinity.record_target_outcome(Some("qwen"), &first, TargetHealthOutcome::Timeout); + let route = select_and_reserve_model_route( + &election::ModelTargets::default(), + &ranked, + "qwen", + None, + &affinity, + None, + ) + .unwrap(); + assert_eq!(route.selection.target, second); + assert_eq!(route.ordered, vec![second.clone(), fallback.clone()]); + } + } + + #[test] + fn refreshed_route_preserves_healthy_cache_and_session_affinity_under_pressure() { + let affinity = AffinityRouter::with_config(true, true); + let ranked = RankedCandidates { + ordered: vec![ + election::InferenceTarget::Local(9001), + election::InferenceTarget::Local(9002), + ], + equivalent_prefix: 2, + }; + let body = serde_json::json!({"user": "same-session"}); + for (parsed_body, cache_target) in + [(None, Some(ranked.ordered[0].clone())), (Some(&body), None)] + { + let select = || { + select_and_reserve_model_route( + &election::ModelTargets::default(), + &ranked, + "qwen", + parsed_body, + &affinity, + cache_target.clone(), + ) + .unwrap() + }; + let held = select(); + let next = select(); + assert!(held.selection.affinity_applied); + assert!(next.selection.affinity_applied); + assert_eq!(next.selection.target, held.selection.target); + } + assert_eq!(affinity.stats_snapshot().reservation_active, 0); + } + + #[test] + fn refreshed_route_keeps_all_cooling_availability_fallback() { + let affinity = AffinityRouter::new(); + let ranked = RankedCandidates { + ordered: vec![ + election::InferenceTarget::Local(9001), + election::InferenceTarget::Local(9002), + ], + equivalent_prefix: 2, + }; + for target in &ranked.ordered { + affinity.record_target_outcome(Some("qwen"), target, TargetHealthOutcome::Timeout); + } + let route = select_and_reserve_model_route( + &election::ModelTargets::default(), + &ranked, + "qwen", + None, + &affinity, + None, + ) + .unwrap(); + assert!(ranked.ordered.contains(&route.selection.target)); + assert_eq!(route.ordered.len(), 2); + } + + #[test] + fn empty_refreshed_route_does_not_reserve() { + let affinity = AffinityRouter::new(); + assert!( + select_and_reserve_model_route( + &election::ModelTargets::default(), + &RankedCandidates { + ordered: vec![], + equivalent_prefix: 0, + }, + "qwen", + None, + &affinity, + Some(election::InferenceTarget::Local(9001)), + ) + .is_none() + ); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); + } + async fn cache_context( prefix_hash: u64, target: election::InferenceTarget, @@ -519,6 +723,7 @@ mod tests { target: target.clone(), prefix_hash: Some(prefix_hash), cache_target: Some(target.clone()), + affinity_applied: true, }; let state = RouteModelState { route_started: Instant::now(), diff --git a/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs b/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs index 34440197b7..8813daa4d8 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs @@ -3,6 +3,29 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; type PromptShapeObservation = (Option, Option, Option); +fn mesh_request_plan( + model: &str, + target_hosts: Vec, + affinity_applied: bool, +) -> MeshRequestPlan { + mesh_request_plan_with_equivalents(model, target_hosts.len(), target_hosts, affinity_applied) +} + +fn mesh_request_plan_with_equivalents( + model: &str, + equivalent_hosts: usize, + target_hosts: Vec, + affinity_applied: bool, +) -> MeshRequestPlan { + MeshRequestPlan { + effective_model: Some(model.to_string()), + auto_session_key: None, + equivalent_hosts, + target_hosts, + affinity_applied, + } +} + #[derive(Default)] struct PromptShapeSink { observations: std::sync::Mutex>, @@ -214,6 +237,61 @@ fn test_remote_retry_policy_only_retries_uncommitted_failures() { )); } +#[test] +fn passive_mesh_plan_spreads_overlap_and_releases_both_reservations() { + let first = iroh::EndpointId::from(iroh::SecretKey::generate().public()); + let second = iroh::EndpointId::from(iroh::SecretKey::generate().public()); + let plan = mesh_request_plan("test", vec![first, second], false); + let affinity = AffinityRouter::new(); + + let (first_order, first_reservation) = reserve_mesh_request_target(&plan, &affinity); + let (second_order, second_reservation) = reserve_mesh_request_target(&plan, &affinity); + + assert_eq!(first_order, vec![first, second]); + assert_eq!(second_order, vec![second, first]); + assert_eq!(affinity.stats_snapshot().reservation_active, 2); + drop(first_reservation); + drop(second_reservation); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + +#[test] +fn mesh_plan_pressure_never_promotes_a_lower_throughput_ranked_host() { + // The fast host forms its own throughput tier (equivalent_hosts = 1). + // Concurrent unaffined requests must all keep the fast host first even + // though the slower host has zero in-flight reservations. + let fast = iroh::EndpointId::from(iroh::SecretKey::generate().public()); + let slow = iroh::EndpointId::from(iroh::SecretKey::generate().public()); + let plan = mesh_request_plan_with_equivalents("test", 1, vec![fast, slow], false); + let affinity = AffinityRouter::new(); + + let (first_order, first_reservation) = reserve_mesh_request_target(&plan, &affinity); + let (second_order, second_reservation) = reserve_mesh_request_target(&plan, &affinity); + + assert_eq!(first_order, vec![fast, slow]); + assert_eq!(second_order, vec![fast, slow]); + drop(first_reservation); + drop(second_reservation); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + +#[test] +fn passive_mesh_plan_keeps_affinity_authoritative_under_pressure() { + let first = iroh::EndpointId::from(iroh::SecretKey::generate().public()); + let second = iroh::EndpointId::from(iroh::SecretKey::generate().public()); + let unaffined = mesh_request_plan("test", vec![first, second], false); + let affined = mesh_request_plan("test", vec![first, second], true); + let affinity = AffinityRouter::new(); + + let (_, pressure) = reserve_mesh_request_target(&unaffined, &affinity); + let (affined_order, affined_reservation) = reserve_mesh_request_target(&affined, &affinity); + + assert_eq!(affined_order, vec![first, second]); + drop(pressure); + drop(affined_reservation); + assert_eq!(affinity.stats_snapshot().reservation_active, 0); +} + #[tokio::test] async fn remote_tokenizer_plan_routes_identity_model_without_context_rejection() -> Result<()> { let model = "acme/code-model:Q4_K_M"; @@ -329,7 +407,8 @@ async fn prefix_kill_switch_prevents_cache_evidence_reordering() -> Result<()> { }); node.insert_test_peer(peer).await; - let ordered = order_mesh_target_hosts(&node, Some(model), None, &mut prepared, &affinity).await; + let (ordered, _) = + order_mesh_target_hosts(&node, Some(model), None, &mut prepared, &affinity).await; assert_eq!(ordered, vec![peer_id]); assert!(prepared.cache_target.is_none()); diff --git a/crates/mesh-llm-host-runtime/src/network/reservations.rs b/crates/mesh-llm-host-runtime/src/network/reservations.rs new file mode 100644 index 0000000000..6ceda96fc1 --- /dev/null +++ b/crates/mesh-llm-host-runtime/src/network/reservations.rs @@ -0,0 +1,317 @@ +//! Process-local in-flight counts used to spread concurrent new sessions +//! across equivalent inference targets. + +use crate::inference::election; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct ReservationKey { + model: String, + target: election::InferenceTarget, +} + +type ReservationCounts = Arc>>; + +fn decrement(counts: &mut HashMap, key: &ReservationKey) { + if let Some(count) = counts.get_mut(key) { + *count = count.saturating_sub(1); + if *count == 0 { + counts.remove(key); + } + } +} + +/// Per-process counts of requests currently in flight to each `(model, +/// target)` pair. Entries exist only while a [`RoutingReservation`] guard is +/// alive, so the map stays bounded by the number of in-flight requests. +#[derive(Clone, Default)] +pub(crate) struct RoutingReservations { + counts: ReservationCounts, +} + +impl RoutingReservations { + /// Choose and reserve a target in one critical section. Existing affinity + /// stays authoritative; otherwise the current picker result is the stable + /// tie-breaker among targets with the fewest local in-flight requests. + /// + /// `spread_limit` bounds spreading to the leading `candidates[..limit]`, + /// which callers set to the run of throughput-equivalent targets at the + /// head of the ranked candidate order. Reservation pressure only trades + /// off targets the measurements cannot distinguish; it never redirects a + /// request to a lower-ranked (measurably slower or smaller-context) + /// target, and a preferred target outside the leading run is reserved + /// as-is. + pub(crate) fn reserve( + &self, + model: &str, + candidates: &[election::InferenceTarget], + spread_limit: usize, + preferred: &election::InferenceTarget, + affinity_applied: bool, + ) -> Option<(election::InferenceTarget, RoutingReservation)> { + if candidates.is_empty() { + return None; + } + let spread_limit = spread_limit.clamp(1, candidates.len()); + let mut counts = self.counts.lock().unwrap(); + let count = |counts: &HashMap, + target: &election::InferenceTarget| { + counts + .get(&ReservationKey { + model: model.to_string(), + target: target.clone(), + }) + .copied() + .unwrap_or(0) + }; + let preferred_index = candidates + .iter() + .position(|candidate| candidate == preferred) + .unwrap_or(0); + let target = if affinity_applied || preferred_index >= spread_limit { + candidates[preferred_index].clone() + } else { + let spread = &candidates[..spread_limit]; + let minimum = spread + .iter() + .map(|candidate| count(&counts, candidate)) + .min() + .unwrap_or(0); + (0..spread.len()) + .map(|offset| (preferred_index + offset) % spread.len()) + .find(|index| count(&counts, &spread[*index]) == minimum) + .map(|index| spread[index].clone()) + .unwrap_or_else(|| candidates[preferred_index].clone()) + }; + let key = ReservationKey { + model: model.to_string(), + target: target.clone(), + }; + *counts.entry(key.clone()).or_insert(0) += 1; + drop(counts); + Some(( + target, + RoutingReservation { + counts: Arc::clone(&self.counts), + key, + }, + )) + } + + /// Total in-flight reservations across all models and targets. + pub(crate) fn active_total(&self) -> usize { + self.counts.lock().unwrap().values().sum() + } + + #[cfg(test)] + fn active_count(&self, model: &str, target: &election::InferenceTarget) -> usize { + self.counts + .lock() + .unwrap() + .get(&ReservationKey { + model: model.to_string(), + target: target.clone(), + }) + .copied() + .unwrap_or(0) + } +} + +/// RAII guard for one in-flight request. Dropping it — on success, failure, +/// cancellation, or panic — releases the reservation. +pub(crate) struct RoutingReservation { + counts: ReservationCounts, + key: ReservationKey, +} + +impl RoutingReservation { + /// Move the reservation to the failover target so retries keep the + /// in-flight count on the target actually serving the request. + pub(crate) fn transfer_to(&mut self, target: &election::InferenceTarget) { + if self.key.target == *target { + return; + } + let mut counts = self.counts.lock().unwrap(); + decrement(&mut counts, &self.key); + self.key.target = target.clone(); + *counts.entry(self.key.clone()).or_insert(0) += 1; + } +} + +impl Drop for RoutingReservation { + fn drop(&mut self) { + decrement(&mut self.counts.lock().unwrap(), &self.key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::Barrier; + + fn local(port: u16) -> election::InferenceTarget { + election::InferenceTarget::Local(port) + } + + fn concurrent_burst( + targets: Vec, + requests: usize, + ) -> BTreeMap { + let reservations = RoutingReservations::default(); + let barrier = Arc::new(Barrier::new(requests)); + let selected = Arc::new(Mutex::new(Vec::new())); + std::thread::scope(|scope| { + for _ in 0..requests { + let reservations = reservations.clone(); + let barrier = Arc::clone(&barrier); + let selected = Arc::clone(&selected); + let targets = targets.clone(); + scope.spawn(move || { + let (target, reservation) = reservations + .reserve("model", &targets, targets.len(), &targets[0], false) + .expect("reservation"); + selected.lock().unwrap().push(target); + barrier.wait(); + drop(reservation); + }); + } + }); + assert_eq!(reservations.active_total(), 0); + let mut counts = BTreeMap::new(); + for target in selected.lock().unwrap().drain(..) { + let election::InferenceTarget::Local(port) = target else { + panic!("expected local target"); + }; + *counts.entry(port).or_insert(0) += 1; + } + counts + } + + #[test] + fn concurrent_new_requests_spread_across_two_targets() { + assert_eq!( + concurrent_burst(vec![local(1), local(2)], 8), + BTreeMap::from([(1, 4), (2, 4)]) + ); + } + + #[test] + fn concurrent_new_requests_spread_across_three_targets() { + assert_eq!( + concurrent_burst(vec![local(1), local(2), local(3)], 9), + BTreeMap::from([(1, 3), (2, 3), (3, 3)]) + ); + } + + #[test] + fn single_target_routing_is_unchanged() { + assert_eq!( + concurrent_burst(vec![local(1)], 8), + BTreeMap::from([(1, 8)]) + ); + } + + #[test] + fn reservation_pressure_never_displaces_a_higher_ranked_target() { + // Candidate order encodes measured throughput rank: target 1 is + // materially faster and forms a rank tier of its own (spread limit 1). + // Even with in-flight requests on the fast target and none on the + // slow one, new sessions must keep going to the fast target. + let reservations = RoutingReservations::default(); + let targets = vec![local(1), local(2)]; + let mut guards = Vec::new(); + for _ in 0..4 { + let (selected, guard) = reservations + .reserve("model", &targets, 1, &targets[0], false) + .expect("reservation"); + assert_eq!(selected, targets[0]); + guards.push(guard); + } + assert_eq!(reservations.active_count("model", &targets[0]), 4); + assert_eq!(reservations.active_count("model", &targets[1]), 0); + } + + #[test] + fn reservation_pressure_spreads_only_within_the_equivalent_tier() { + // Targets 1 and 2 are throughput-equivalent (spread limit 2); target 3 + // is a lower tier. Concurrent requests alternate between the first two + // and never spill to the third. + let reservations = RoutingReservations::default(); + let targets = vec![local(1), local(2), local(3)]; + let mut guards = Vec::new(); + let mut selections = Vec::new(); + for _ in 0..4 { + let (selected, guard) = reservations + .reserve("model", &targets, 2, &targets[0], false) + .expect("reservation"); + selections.push(selected); + guards.push(guard); + } + assert_eq!(reservations.active_count("model", &targets[0]), 2); + assert_eq!(reservations.active_count("model", &targets[1]), 2); + assert_eq!(reservations.active_count("model", &targets[2]), 0); + } + + #[test] + fn preferred_target_outside_the_spread_window_is_reserved_as_is() { + // A sticky/round-robin pick may land past the equivalent tier; the + // reservation must follow it rather than pull the request forward. + let reservations = RoutingReservations::default(); + let targets = vec![local(1), local(2)]; + let (selected, _guard) = reservations + .reserve("model", &targets, 1, &targets[1], false) + .expect("reservation"); + assert_eq!(selected, targets[1]); + assert_eq!(reservations.active_count("model", &targets[1]), 1); + } + + #[test] + fn established_affinity_ignores_reservation_pressure() { + let reservations = RoutingReservations::default(); + let targets = vec![local(1), local(2)]; + let (_, _first) = reservations + .reserve("model", &targets, targets.len(), &targets[0], false) + .expect("first reservation"); + let (selected, _sticky) = reservations + .reserve("model", &targets, targets.len(), &targets[0], true) + .expect("sticky reservation"); + + assert_eq!(selected, targets[0]); + assert_eq!(reservations.active_count("model", &targets[0]), 2); + assert_eq!(reservations.active_count("model", &targets[1]), 0); + } + + #[test] + fn reservation_pressure_is_isolated_per_model() { + let reservations = RoutingReservations::default(); + let targets = vec![local(1), local(2)]; + let (_, _model_a) = reservations + .reserve("model-a", &targets, targets.len(), &targets[0], false) + .expect("model-a reservation"); + let (selected, _model_b) = reservations + .reserve("model-b", &targets, targets.len(), &targets[0], false) + .expect("model-b reservation"); + + assert_eq!(selected, targets[0]); + assert_eq!(reservations.active_count("model-a", &targets[0]), 1); + assert_eq!(reservations.active_count("model-b", &targets[0]), 1); + } + + #[test] + fn failover_transfers_and_drop_releases_reservation() { + let reservations = RoutingReservations::default(); + let targets = vec![local(1), local(2)]; + let (_, mut reservation) = reservations + .reserve("model", &targets, targets.len(), &targets[0], false) + .expect("reservation"); + + reservation.transfer_to(&targets[1]); + assert_eq!(reservations.active_count("model", &targets[0]), 0); + assert_eq!(reservations.active_count("model", &targets[1]), 1); + drop(reservation); + + assert_eq!(reservations.active_total(), 0); + } +} diff --git a/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs b/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs index e0a750ea79..b38afcf387 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs @@ -17,21 +17,33 @@ use tokio::sync::{oneshot, watch}; async fn spawn_api_proxy_test_harness( targets: election::ModelTargets, ) -> (SocketAddr, tokio::task::JoinHandle<()>) { + let (addr, handle, _) = spawn_api_proxy_test_harness_with_affinity(targets).await; + (addr, handle) +} + +async fn spawn_api_proxy_test_harness_with_affinity( + targets: election::ModelTargets, +) -> ( + SocketAddr, + tokio::task::JoinHandle<()>, + affinity::AffinityRouter, +) { let node = mesh::Node::new_for_tests(mesh::NodeRole::Worker) .await .unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let (_target_tx, target_rx) = watch::channel(targets); + let affinity = affinity::AffinityRouter::default(); let handle = tokio::spawn(api_proxy( node, addr.port(), target_rx, Some(listener), false, - affinity::AffinityRouter::default(), + affinity.clone(), )); - (addr, handle) + (addr, handle, affinity) } async fn spawn_api_proxy_test_harness_with_contexts( @@ -343,6 +355,43 @@ async fn spawn_status_upstream( (port, request_rx, handle) } +async fn spawn_held_upstream( + response_body: &str, +) -> ( + u16, + Arc, + Arc, + tokio::task::JoinHandle<()>, +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let response = response_body.to_string(); + let accepted = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let task_accepted = Arc::clone(&accepted); + let task_release = Arc::clone(&release); + let handle = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let response = response.clone(); + let accepted = Arc::clone(&task_accepted); + let release = Arc::clone(&task_release); + tokio::spawn(async move { + let _raw = read_raw_http_request(&mut stream).await; + accepted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _permit = release.acquire().await.expect("release semaphore"); + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.len(), + response + ); + stream.write_all(reply.as_bytes()).await.unwrap(); + let _ = stream.shutdown().await; + }); + } + }); + (port, accepted, release, handle) +} + async fn spawn_streaming_upstream( content_type: &str, chunks: Vec<(Duration, Vec)>, diff --git a/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/routing.rs b/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/routing.rs index 19eb265cbb..671df01f9a 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/routing.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/routing.rs @@ -5,8 +5,10 @@ async fn test_api_proxy_retries_context_overflow_bad_request_to_next_target() { let (small_port, small_rx, small_handle) = spawn_status_upstream("400 Bad Request", overflow_body).await; let (large_port, large_rx, large_handle) = spawn_capturing_upstream(r#"{"ok":true}"#).await; - let (proxy_addr, proxy_handle) = - spawn_api_proxy_test_harness(single_model_targets("test", &[small_port, large_port])).await; + let (proxy_addr, proxy_handle, affinity) = spawn_api_proxy_test_harness_with_affinity( + single_model_targets("test", &[small_port, large_port]), + ) + .await; let body = json!({ "model": "test", @@ -27,12 +29,96 @@ async fn test_api_proxy_retries_context_overflow_bad_request_to_next_target() { assert!(response.contains(r#"{"ok":true}"#)); assert!(first_raw.contains("overflow then retry")); assert!(second_raw.contains("overflow then retry")); + let stats = affinity.stats_snapshot(); + assert_eq!(stats.reservation_active, 0); proxy_handle.abort(); let _ = small_handle.await; let _ = large_handle.await; } +#[tokio::test] +async fn real_proxy_avoids_the_round_robin_target_that_is_still_in_flight() { + let (first_port, first_count, first_release, first_handle) = + spawn_held_upstream(r#"{"ok":"first"}"#).await; + let (second_port, second_count, second_release, second_handle) = + spawn_held_upstream(r#"{"ok":"second"}"#).await; + let (proxy_addr, proxy_handle, affinity) = spawn_api_proxy_test_harness_with_affinity( + single_model_targets("test", &[first_port, second_port]), + ) + .await; + + let request = |index| { + let body = json!({ + "model": "test", + "messages": [{"role": "user", "content": format!("burst {index}")}], + }) + .to_string(); + format!( + "POST /v1/chat/completions HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + }; + + let first_request = tokio::spawn(send_request_and_read_response( + proxy_addr, + vec![request(0).into_bytes()], + )); + tokio::time::timeout(Duration::from_secs(2), async { + while first_count.load(std::sync::atomic::Ordering::SeqCst) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .expect("first request reached the first round-robin target"); + + let second_request = tokio::spawn(send_request_and_read_response( + proxy_addr, + vec![request(1).into_bytes()], + )); + tokio::time::timeout(Duration::from_secs(2), async { + while second_count.load(std::sync::atomic::Ordering::SeqCst) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .expect("second request reached the second round-robin target"); + + second_release.add_permits(1); + let response = second_request.await.expect("second request task"); + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert_eq!(affinity.stats_snapshot().reservation_active, 1); + + let third_request = tokio::spawn(send_request_and_read_response( + proxy_addr, + vec![request(2).into_bytes()], + )); + tokio::time::timeout(Duration::from_secs(2), async { + while second_count.load(std::sync::atomic::Ordering::SeqCst) != 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("reservation pressure redirected the third request"); + + assert_eq!(first_count.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(second_count.load(std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(affinity.stats_snapshot().reservation_active, 2); + + first_release.add_permits(1); + second_release.add_permits(1); + for request in [first_request, third_request] { + let response = request.await.expect("request task"); + assert!(response.starts_with("HTTP/1.1 200 OK")); + } + assert_eq!(affinity.stats_snapshot().reservation_active, 0); + + proxy_handle.abort(); + first_handle.abort(); + second_handle.abort(); +} + #[tokio::test] async fn test_api_proxy_preserves_context_overflow_bad_request_for_single_target() { let overflow_body = diff --git a/crates/mesh-llm-routing/src/affinity.rs b/crates/mesh-llm-routing/src/affinity.rs index fcdc7e2153..5f85faaa97 100644 --- a/crates/mesh-llm-routing/src/affinity.rs +++ b/crates/mesh-llm-routing/src/affinity.rs @@ -157,6 +157,8 @@ pub struct TargetSelection { pub prefix_hash: Option, /// Cache-evidence target used for this request, when one was available. pub cache_target: Option, + /// Whether cache, session, or explicit prefix affinity chose the target. + pub affinity_applied: bool, } /// Remote-target ordering and cache-evidence metadata. @@ -167,6 +169,8 @@ pub struct PreparedTargets { pub prefix_hash: Option, /// Cache-evidence target moved first, when one was available. pub cache_target: Option, + /// Whether cache, session, or explicit prefix affinity ordered the targets. + pub affinity_applied: bool, } /// Whether prefix-only routing has been requested by the process. @@ -294,6 +298,7 @@ pub fn select_model_target_from_keys( target: target.clone(), prefix_hash: routing.prefix_hash, cache_target: Some(target), + affinity_applied: true, }; } if routing.prefix_hash.is_some() { @@ -305,6 +310,7 @@ pub fn select_model_target_from_keys( target: ModelTargets::pick_sticky_from(candidates, session_hash), prefix_hash: routing.prefix_hash, cache_target: None, + affinity_applied: true, }; } @@ -314,6 +320,7 @@ pub fn select_model_target_from_keys( target: ModelTargets::pick_sticky_from(candidates, prefix_hash), prefix_hash: Some(prefix_hash), cache_target: None, + affinity_applied: true, }; } @@ -323,6 +330,7 @@ pub fn select_model_target_from_keys( target: ModelTargets::pick_sticky_from(candidates, sticky_hash), prefix_hash: Some(prefix_hash), cache_target: None, + affinity_applied: true, }; } @@ -330,6 +338,7 @@ pub fn select_model_target_from_keys( target: targets.pick_from(candidates), prefix_hash: Some(prefix_hash), cache_target: None, + affinity_applied: false, }; } @@ -339,6 +348,7 @@ pub fn select_model_target_from_keys( target: ModelTargets::pick_sticky_from(candidates, sticky_hash), prefix_hash: None, cache_target: None, + affinity_applied: true, }; } @@ -346,6 +356,7 @@ pub fn select_model_target_from_keys( target: targets.pick_from(candidates), prefix_hash: None, cache_target: None, + affinity_applied: false, } } @@ -359,6 +370,7 @@ pub fn prepare_remote_targets_from_keys( let mut ordered: Vec = hosts.iter().copied().map(InferenceTarget::Remote).collect(); let mut cache_target = cache_target.filter(|target| ordered.contains(target)); + let mut affinity_applied = false; if affinity.prefix_enabled() && let Some(target) = cache_target.as_ref() @@ -369,6 +381,7 @@ pub fn prepare_remote_targets_from_keys( ordered, prefix_hash: routing.prefix_hash, cache_target, + affinity_applied: true, }; } if let Some(session_hash) = routing.session_hash.filter(|_| affinity.sticky_enabled()) { @@ -378,26 +391,31 @@ pub fn prepare_remote_targets_from_keys( ordered, prefix_hash: routing.prefix_hash, cache_target: None, + affinity_applied: true, }; } if let Some(prefix_hash) = routing.prefix_hash { if prefix_only_enabled() { rotate_targets_by_hash(&mut ordered, prefix_hash); + affinity_applied = true; } else if let Some(sticky_hash) = routing.sticky_hash.filter(|_| affinity.sticky_enabled()) { affinity.record_sticky_route(); rotate_targets_by_hash(&mut ordered, sticky_hash); + affinity_applied = true; } } else if let Some(sticky_hash) = routing.sticky_hash.filter(|_| affinity.sticky_enabled()) { affinity.record_sticky_route(); rotate_targets_by_hash(&mut ordered, sticky_hash); + affinity_applied = true; } PreparedTargets { ordered, prefix_hash: routing.prefix_hash, cache_target: cache_target.take(), + affinity_applied, } } diff --git a/docs/design/MOA_GATEWAY.md b/docs/design/MOA_GATEWAY.md index 52d389dbde..7a1256e417 100644 --- a/docs/design/MOA_GATEWAY.md +++ b/docs/design/MOA_GATEWAY.md @@ -5,7 +5,7 @@ arbitrate responses with deterministic logic, manage tool call lifecycles, and return one coherent OpenAI-compatible response. **Crate:** `crates/mesh-mixture-of-agents/` -**Virtual model:** `model: "mesh"` (not advertised in `/v1/models`) +**Automatic directive:** `model: "mesh"` (`auto` is a deprecated alias; advertised when a model is available) **Status:** Integrated into mesh proxy, live-tested with mesh peers --- @@ -171,6 +171,15 @@ for standalone/test use. The host runtime implements mesh-native backends: | `RemoteModelBackend` | HTTP-over-QUIC tunnel via `node.open_http_tunnel()` | Remote mesh peer | | `HttpBackend` | Plain HTTP to any URL | Standalone testing | +Same-model self-fill keeps two distinct physical endpoints per committee. +Concurrent turns at one gateway reserve the least-loaded clones within each +leading context/throughput-equivalent tier; a slower or unknown-context clone +does not displace an available higher-ranked clone merely because it is idle. +Reservations last while the turn's backend references survive, including +worker/reducer calls, and release on completion or cancellation. Self-fill +slots do not fail over onto sibling clones. This is process-local accounting, +not global admission, and does not change heterogeneous-model replica selection. + All worker requests set `mesh_hooks: false` to prevent recursive virtual LLM consultations (MoA → model → hook → consult another model → ...). @@ -320,7 +329,7 @@ through unchanged. | System | What it does | Relationship to MoA | |--------|-------------|---------------------| -| `auto` | Routes to best single model | MoA fans out to ALL models | +| `auto` / `mesh` | Same automatic directive | Committee-capable chat uses MoA; streaming, media, non-chat, or unavailable workers use ordinary routing | | Hooks (`virtual_llm.rs`) | Reactive during inference (entropy/drift/image) | MoA is proactive before inference | | Consult (`consult.rs`) | Single peer consultation over QUIC | MoA does parallel multi-peer | | Pipeline (`pipeline.rs`) | 2-model plan→execute for code tasks | Complementary, used at ingress line 279 | From f52ba5db666f2c0aba37ed5541a7648f939a3e99 Mon Sep 17 00:00:00 2001 From: scama Date: Thu, 10 Sep 2026 11:19:54 +1000 Subject: [PATCH 05/41] fix(evals): make restart replay cohorts valid --- evals/kv-restart-replay.py | 61 ++++---- scripts/tests/test_kv_restart_replay.py | 184 ++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 24 deletions(-) create mode 100644 scripts/tests/test_kv_restart_replay.py diff --git a/evals/kv-restart-replay.py b/evals/kv-restart-replay.py index 0256b34056..8a5cf380da 100644 --- a/evals/kv-restart-replay.py +++ b/evals/kv-restart-replay.py @@ -56,8 +56,10 @@ "--ctx-size", "--generation-concurrency", "--generation-queue-capacity", + "--host", "--max-vram", "--parallel", + "--port", ) # Deterministic manifest vocabulary. The conversation simulates a long-running @@ -136,7 +138,11 @@ def block(target_tokens: int, topic: str) -> str: f"{' '.join(rng.words(6))} constraint in one sentence and list the " f"{' '.join(rng.words(4))} next step." ) - turn_specs.append({"context": body, "request": request}) + response = ( + f"Turn {index + 1} answer: preserve the {' '.join(rng.words(5))} " + f"constraint. Next step: verify {' '.join(rng.words(4))}." + ) + turn_specs.append({"context": body, "request": request, "response": response}) return { "schema_version": SCHEMA_VERSION, @@ -331,8 +337,8 @@ def stream_request( first_token_at = time.monotonic() if first_token_at is None: return {"request_id": request_id, "error": "stream completed without content tokens"} - if not saw_done and completion_tokens == 0: - return {"request_id": request_id, "error": "stream completed without completion-token usage"} + if not saw_done: + return {"request_id": request_id, "error": "stream ended without terminal [DONE] marker"} ended = time.monotonic() return { "request_id": request_id, @@ -377,11 +383,17 @@ def hardware_fingerprint() -> dict[str, Any]: ["sysctl", "-n", "hw.model"], capture_output=True, text=True, check=True ).stdout.strip() ) - except subprocess.CalledProcessError: + except (OSError, subprocess.CalledProcessError): pass try: - fingerprint["physical_memory_bytes"] = os.sysconf("HW_PHYSMEM") - except (ValueError, OSError): + memory = subprocess.run( + ["sysctl", "-n", "hw.memsize"], + capture_output=True, + text=True, + check=True, + ) + fingerprint["physical_memory_bytes"] = int(memory.stdout.strip()) + except (OSError, subprocess.CalledProcessError, ValueError): fingerprint["physical_memory_bytes"] = None fingerprint["cpu_core_count"] = os.cpu_count() else: @@ -413,6 +425,7 @@ def binary_provenance(binary: Path) -> dict[str, Any]: provenance["source_sha"] = commit.stdout.strip() except (subprocess.CalledProcessError, OSError): provenance["git_describe"] = "unknown" + provenance["source_sha"] = "unknown" return provenance @@ -441,7 +454,7 @@ def percentile(values: Sequence[float], fraction: float) -> Optional[float]: "requests": len(rows), "failed": len(failed), "ttft_p50_seconds": percentile(ttft, 0.50), - "ttft_p95_seconds": percentile(ttft, 0.95), + "ttft_p95_seconds": percentile(ttft, 0.95) if len(ttft) > 1 else None, "total_seconds_mean": statistics.fmean(row["total_seconds"] for row in successful) if successful else None, "prompt_tokens": prompt_tokens, "cached_tokens": cached_tokens, @@ -467,13 +480,18 @@ def run_arm(args: argparse.Namespace, output: Path) -> dict[str, Any]: raise FileNotFoundError(f"binary not found: {binary}") if not model_path.exists(): raise FileNotFoundError(f"model not found: {model_path}") + if args.turns < 1: + raise ValueError("turns must be at least 1") + if args.restore_repeats < 1: + raise ValueError("restore-repeats must be at least 1") manifest = build_manifest(args.turns, args.turn_target_tokens, args.system_tokens) manifest_sha = stable_hash(manifest) conversation: list[dict[str, Any]] = [{"role": "system", "content": manifest["system"]}] for spec in manifest["turns"]: conversation.append({"role": "user", "content": f"{spec['context']}\n\n{spec['request']}"}) - conversation.append({"role": "assistant", "content": spec["request"]}) + conversation.append({"role": "assistant", "content": spec["response"]}) + frozen_prompt = messages_through(conversation, args.turns - 1) state_dir = (output / "server-state").resolve() state_dir.mkdir(parents=True, exist_ok=True) @@ -486,18 +504,11 @@ def record(cohort: str, index: int, result: dict[str, Any]) -> None: with requests_path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(row, sort_keys=True) + "\n") - def replay_frozen(cohort: str, repeats: Optional[int] = None) -> None: - connection = http.client.HTTPConnection(DEFAULT_HOST, DEFAULT_PORT, timeout=5) - try: - connection.request("GET", "/v1/models") - document = json.loads(connection.getresponse().read()) - model_id = (document.get("data") or [{}])[0].get("id", "default") - finally: - connection.close() + def replay_frozen(cohort: str, model_id: str, repeats: Optional[int] = None) -> None: for repeat in range(args.restore_repeats if repeats is None else repeats): result = stream_request( f"{cohort}-{repeat + 1}", - conversation, + frozen_prompt, model_id, args.max_output_tokens, args.request_timeout, @@ -554,7 +565,7 @@ def replay_frozen(cohort: str, repeats: Optional[int] = None) -> None: process, _ = start_server( binary, str(model_path), args.serve_extra_args, state_dir, output / "logs" / "restore.log" ) - wait_for_model(args.ready_timeout, process) + model_id = wait_for_model(args.ready_timeout, process) restart_gap_seconds = time.monotonic() - stopped_at provenance["restart"] = { "method": "SIGINT to the serving process group, then fresh start on the same state directory", @@ -563,11 +574,8 @@ def replay_frozen(cohort: str, repeats: Optional[int] = None) -> None: # Cohort: restore — the FIRST post-restart replay alone is the # first-request-after-restart measurement; later replays warm from the # resident cache and are recorded under the warm cohort instead. - replay_frozen("restore", repeats=1) - replay_frozen("warm", repeats=max(args.restore_repeats - 1, 0)) - - # Cohort: warm — repeat replays without restart. - replay_frozen("warm") + replay_frozen("restore", model_id, repeats=1) + replay_frozen("warm", model_id, repeats=max(args.restore_repeats - 1, 0)) finally: if process is not None: try: @@ -623,7 +631,12 @@ def main() -> int: parser.add_argument("--turns", type=int, default=4) parser.add_argument("--turn-target-tokens", type=int, default=4750) parser.add_argument("--system-tokens", type=int, default=500) - parser.add_argument("--restore-repeats", type=int, default=3) + parser.add_argument( + "--restore-repeats", + type=int, + default=3, + help="total post-restart requests: one restore followed by warm repeats", + ) parser.add_argument("--max-output-tokens", type=int, default=256) parser.add_argument("--request-timeout", type=float, default=900.0) parser.add_argument("--ready-timeout", type=float, default=900.0) diff --git a/scripts/tests/test_kv_restart_replay.py b/scripts/tests/test_kv_restart_replay.py new file mode 100644 index 0000000000..16dd743203 --- /dev/null +++ b/scripts/tests/test_kv_restart_replay.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / "evals/kv-restart-replay.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("kv_restart_replay", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot import {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +BENCH = load_module() + + +class KvRestartReplayTest(unittest.TestCase): + def test_server_command_rejects_endpoint_overrides(self) -> None: + binary = Path("/tmp/mesh-llm") + + with self.assertRaisesRegex(AssertionError, "--port"): + BENCH.server_command(binary, "model.gguf", ["--port=9447"]) + with self.assertRaisesRegex(AssertionError, "--host"): + BENCH.server_command(binary, "model.gguf", ["--host", "0.0.0.0"]) + + def test_manifest_uses_distinct_deterministic_assistant_responses(self) -> None: + manifest = BENCH.build_manifest(2, 32, 16) + + self.assertNotEqual(manifest["turns"][0]["request"], manifest["turns"][0]["response"]) + self.assertEqual(manifest, BENCH.build_manifest(2, 32, 16)) + + def test_macos_memory_uses_sysctl(self) -> None: + results = [ + subprocess.CompletedProcess([], 0, stdout="Apple M2\n"), + subprocess.CompletedProcess([], 0, stdout="Mac14,6\n"), + subprocess.CompletedProcess([], 0, stdout="17179869184\n"), + ] + with ( + mock.patch.object(BENCH.sys, "platform", "darwin"), + mock.patch.object(BENCH.subprocess, "run", side_effect=results) as run, + ): + fingerprint = BENCH.hardware_fingerprint() + + self.assertEqual(fingerprint["physical_memory_bytes"], 17179869184) + self.assertEqual(run.call_args_list[-1].args[0], ["sysctl", "-n", "hw.memsize"]) + + def test_binary_provenance_keeps_unknown_source_sha_without_git(self) -> None: + with tempfile.TemporaryDirectory() as directory: + binary = Path(directory) / "mesh-llm" + binary.write_bytes(b"binary") + with mock.patch.object(BENCH.subprocess, "run", side_effect=FileNotFoundError): + provenance = BENCH.binary_provenance(binary) + + self.assertEqual(provenance["git_describe"], "unknown") + self.assertEqual(provenance["source_sha"], "unknown") + + def test_stream_request_rejects_truncated_stream_with_usage(self) -> None: + class TruncatedResponse: + status = 200 + + def __iter__(self): + return iter( + [ + b'data: {"choices":[{"delta":{"content":"partial"}}]}\n', + b'data: {"choices":[],"usage":{"completion_tokens":1,"prompt_tokens":10}}\n', + ] + ) + + class TruncatedConnection: + def __init__(self, *_args, **_kwargs): + pass + + def request(self, *_args, **_kwargs): + pass + + def getresponse(self): + return TruncatedResponse() + + def close(self): + pass + + with mock.patch.object(BENCH.http.client, "HTTPConnection", TruncatedConnection): + result = BENCH.stream_request( + "request-1", + [{"role": "user", "content": "task"}], + "model", + 8, + 10, + ) + + self.assertEqual(result["error"], "stream ended without terminal [DONE] marker") + + def test_single_restore_sample_does_not_report_p95(self) -> None: + summary = BENCH.summarize_cohort( + "restore", + [ + { + "ttft_seconds": 0.2, + "total_seconds": 0.3, + "prompt_tokens": 10, + "cached_tokens": 5, + "decode_tokens_per_second": 10.0, + } + ], + ) + + self.assertEqual(summary["ttft_p50_seconds"], 0.2) + self.assertIsNone(summary["ttft_p95_seconds"]) + + def test_run_arm_replays_the_last_fill_prompt_once_per_post_restart_request(self) -> None: + calls: list[tuple[str, list[dict[str, str]]]] = [] + + def fake_stream(request_id, messages, *_args): + calls.append((request_id, list(messages))) + return { + "request_id": request_id, + "ttft_seconds": 0.1, + "total_seconds": 0.2, + "prompt_tokens": 10, + "completion_tokens": 1, + "cached_tokens": 5, + "decode_tokens_per_second": 10.0, + } + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary = root / "mesh-llm" + model = root / "model.gguf" + binary.write_bytes(b"binary") + model.write_bytes(b"model") + args = SimpleNamespace( + binary=str(binary), + model=str(model), + turns=2, + turn_target_tokens=32, + system_tokens=16, + restore_repeats=3, + max_output_tokens=8, + request_timeout=10.0, + ready_timeout=10.0, + serve_extra_args=[], + ) + with ( + mock.patch.object( + BENCH, + "start_server", + side_effect=lambda *_args: (SimpleNamespace(), ["mesh-llm", "serve"]), + ), + mock.patch.object(BENCH, "stop_server"), + mock.patch.object(BENCH, "wait_for_model", return_value="model"), + mock.patch.object(BENCH, "stream_request", side_effect=fake_stream), + mock.patch.object(BENCH, "binary_provenance", return_value={"source_sha": "a" * 40}), + mock.patch.object(BENCH, "hardware_fingerprint", return_value={"platform": "test"}), + ): + run = BENCH.run_arm(args, root / "output") + + self.assertEqual([row["requests"] for row in run["cohorts"]], [2, 1, 2]) + self.assertEqual([request_id for request_id, _ in calls], [ + "fill-1", + "fill-2", + "restore-1", + "warm-1", + "warm-2", + ]) + self.assertTrue(all(messages[-1]["role"] == "user" for _, messages in calls)) + self.assertEqual(calls[1][1], calls[2][1]) + self.assertEqual(calls[2][1], calls[3][1]) + + +if __name__ == "__main__": + unittest.main() From 4e934572e2083ab13d3ecc8014b0e53e33afbe2e Mon Sep 17 00:00:00 2001 From: scama Date: Wed, 9 Sep 2026 16:25:45 +1000 Subject: [PATCH 06/41] fix(skippy): preserve resident KV fast path with L3 --- .../src/kv_integration/config.rs | 43 +++++++++++++------ .../src/kv_integration/exact_state.rs | 20 +++++++-- .../skippy-server/src/kv_integration/mod.rs | 15 ++++++- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/crates/skippy-server/src/kv_integration/config.rs b/crates/skippy-server/src/kv_integration/config.rs index e0cc81da79..c24c3389ea 100644 --- a/crates/skippy-server/src/kv_integration/config.rs +++ b/crates/skippy-server/src/kv_integration/config.rs @@ -85,7 +85,7 @@ impl KvStageIntegration { emit_cache_disabled_warning(config, reason); return Ok(None); } - let mut payload = effective_cache_payload(cache_config.payload, &model_capability); + let payload = effective_cache_payload(cache_config.payload, &model_capability); if payload == StagePrefixCachePayload::Disabled { return Ok(None); } @@ -109,18 +109,24 @@ impl KvStageIntegration { return Ok(None); } let l3_manager = manager()?; - if l3_manager.is_some() - && payload == StagePrefixCachePayload::ResidentKv - && dense_without_recurrent - { - // The durable tier needs exportable state and ResidentKv is - // borrow-only. Dense families record KV pages with an empty - // recurrent snapshot so they reach disk; the model is known - // dense, so the empty snapshot is expected rather than corrupt. - payload = StagePrefixCachePayload::KvRecurrent; - } + let durable_payload = l3_manager.as_ref().map(|_| { + if payload == StagePrefixCachePayload::ResidentKv && dense_without_recurrent { + // Resident KV stays the in-process fast path. Dense families + // export KV pages with an empty recurrent snapshot for L3; + // the known-dense capability makes that empty component valid. + StagePrefixCachePayload::KvRecurrent + } else { + payload + } + }); let l3 = l3_manager - .map(|manager| l3_tier_for_manager(config, payload, manager)) + .map(|manager| { + l3_tier_for_manager( + config, + durable_payload.expect("enabled cache has a durable payload"), + manager, + ) + }) .transpose()?; // FullState is architecture-neutral: the native runtime serializes the // complete session state for both dense and recurrent model families. @@ -206,6 +212,7 @@ impl KvStageIntegration { Ok(Some(Self { mode, payload, + durable_payload, correctness_mode: false, trust_local_writes: true, checkpoint_policy, @@ -1315,7 +1322,7 @@ mod tests { } #[test] - fn dense_auto_cache_becomes_exportable_when_disk_is_injected() { + fn dense_disk_cache_preserves_resident_fast_path_and_exports_exact_state() { let root = std::env::temp_dir() .join("skippy-server-l3-manager-tests") .join(format!("dense-export-{}", std::process::id())); @@ -1331,7 +1338,15 @@ mod tests { .unwrap() .expect("dense disk cache should remain enabled"); - assert_eq!(kv.payload, StagePrefixCachePayload::KvRecurrent); + assert_eq!(kv.payload, StagePrefixCachePayload::ResidentKv); + assert_eq!( + kv.durable_payload, + Some(StagePrefixCachePayload::KvRecurrent) + ); + assert_eq!( + kv.exact_state_payload(), + Some(StagePrefixCachePayload::KvRecurrent) + ); assert!(kv.l3.is_some()); } diff --git a/crates/skippy-server/src/kv_integration/exact_state.rs b/crates/skippy-server/src/kv_integration/exact_state.rs index 5166e2561d..0d8d5eac9a 100644 --- a/crates/skippy-server/src/kv_integration/exact_state.rs +++ b/crates/skippy-server/src/kv_integration/exact_state.rs @@ -33,7 +33,18 @@ impl KvStageIntegration { session_id: &str, identities: &[PrefillKvIdentity], ) -> Result> { - if !self.should_lookup() || !self.payload.is_exact_state() { + if !self.should_lookup() || self.exact_state_payload().is_none() { + return Ok(None); + } + // Dense L3 uses serialized exact state only as the durable floor. + // Prefer a native resident-prefix hit whenever one is already warm; + // importing the serialized snapshot would otherwise make enabling L3 + // slower than the ordinary L1 path on every repeated request. + if self.payload == StagePrefixCachePayload::ResidentKv + && identities + .iter() + .any(|identity| self.probe_resident_prefix(identity).is_some()) + { return Ok(None); } for identity in identities { @@ -253,7 +264,10 @@ impl KvStageIntegration { session_id: &str, identity: &PrefillKvIdentity, ) -> Result> { - if !self.should_record() || !self.payload.is_exact_state() { + let Some(exact_state_payload) = self.exact_state_payload() else { + return Ok(None); + }; + if !self.should_record() { return Ok(None); } let token_count = identity.identity.token_count; @@ -288,7 +302,7 @@ impl KvStageIntegration { self.finish_record(&identity.page_id); return Ok(None); } - let exported = match self.payload { + let exported = match exact_state_payload { StagePrefixCachePayload::FullState => { runtime.export_full_state(session_id).map(|state| { ( diff --git a/crates/skippy-server/src/kv_integration/mod.rs b/crates/skippy-server/src/kv_integration/mod.rs index 55613a2347..11a4b97929 100644 --- a/crates/skippy-server/src/kv_integration/mod.rs +++ b/crates/skippy-server/src/kv_integration/mod.rs @@ -148,7 +148,13 @@ pub(crate) struct ExactStateByteLimits { #[derive(Clone)] pub struct KvStageIntegration { pub(crate) mode: StageKvMode, + /// The in-process cache representation. Dense models keep native resident + /// KV here even when a durable tier is configured, so enabling disk does + /// not replace the fast warm path with serialized state import. pub(crate) payload: StagePrefixCachePayload, + /// Exportable representation written to and restored from L3. This is + /// separate from `payload` because resident KV is native and borrow-only. + pub(crate) durable_payload: Option, pub(crate) correctness_mode: bool, pub(crate) trust_local_writes: bool, pub(crate) checkpoint_policy: SparseCheckpointPolicy, @@ -502,7 +508,14 @@ impl KvStageIntegration { } pub(crate) fn payload_is_exact_state(&self) -> bool { - self.payload.is_exact_state() + self.exact_state_payload().is_some() + } + + pub(crate) fn exact_state_payload(&self) -> Option { + self.payload + .is_exact_state() + .then_some(self.payload) + .or(self.durable_payload) } pub fn should_lookup(&self) -> bool { From e43f42eab8f8e9e5047b66ca41ea2673c553eb47 Mon Sep 17 00:00:00 2001 From: scama Date: Thu, 10 Sep 2026 11:18:37 +1000 Subject: [PATCH 07/41] fix(skippy): prefer longer durable restore --- .../src/kv_integration/config.rs | 14 ++++++------ .../src/kv_integration/exact_state.rs | 22 +++++++++++++++---- .../skippy-server/src/kv_integration/mod.rs | 4 +++- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/skippy-server/src/kv_integration/config.rs b/crates/skippy-server/src/kv_integration/config.rs index c24c3389ea..75f0efba06 100644 --- a/crates/skippy-server/src/kv_integration/config.rs +++ b/crates/skippy-server/src/kv_integration/config.rs @@ -120,13 +120,8 @@ impl KvStageIntegration { } }); let l3 = l3_manager - .map(|manager| { - l3_tier_for_manager( - config, - durable_payload.expect("enabled cache has a durable payload"), - manager, - ) - }) + .zip(durable_payload) + .map(|(manager, payload)| l3_tier_for_manager(config, payload, manager)) .transpose()?; // FullState is architecture-neutral: the native runtime serializes the // complete session state for both dense and recurrent model families. @@ -1348,6 +1343,11 @@ mod tests { Some(StagePrefixCachePayload::KvRecurrent) ); assert!(kv.l3.is_some()); + + let mut invalid = kv; + invalid.payload = StagePrefixCachePayload::Disabled; + invalid.durable_payload = Some(StagePrefixCachePayload::ResidentKv); + assert_eq!(invalid.exact_state_payload(), None); } #[test] diff --git a/crates/skippy-server/src/kv_integration/exact_state.rs b/crates/skippy-server/src/kv_integration/exact_state.rs index 0d8d5eac9a..acd53a3c82 100644 --- a/crates/skippy-server/src/kv_integration/exact_state.rs +++ b/crates/skippy-server/src/kv_integration/exact_state.rs @@ -15,6 +15,10 @@ fn l3_fill_claim_key(l3: &skippy_cache::L3Tier, location: &skippy_cache::L3Locat format!("{}:{}", l3.state_identity(), location.manifest_key) } +fn resident_prefix_is_complete(matched_tokens: usize, requested_tokens: usize) -> bool { + matched_tokens >= requested_tokens +} + impl KvStageIntegration { pub fn restore_exact_state( &self, @@ -41,9 +45,12 @@ impl KvStageIntegration { // importing the serialized snapshot would otherwise make enabling L3 // slower than the ordinary L1 path on every repeated request. if self.payload == StagePrefixCachePayload::ResidentKv - && identities - .iter() - .any(|identity| self.probe_resident_prefix(identity).is_some()) + && identities.iter().any(|identity| { + self.probe_resident_prefix(identity) + .is_some_and(|resident| { + resident_prefix_is_complete(resident.token_count, identity.token_ids.len()) + }) + }) { return Ok(None); } @@ -691,13 +698,20 @@ mod tests { use skippy_cache::UnifiedRadixCache; - use super::try_touch_exact_state; + use super::{resident_prefix_is_complete, try_touch_exact_state}; type TestRadix = UnifiedRadixCache< crate::kv_integration::RadixResidentEntry, crate::kv_integration::RadixExactEntry, >; + #[test] + fn only_complete_resident_prefixes_skip_exact_restore() { + assert!(resident_prefix_is_complete(4_000, 4_000)); + assert!(resident_prefix_is_complete(4_001, 4_000)); + assert!(!resident_prefix_is_complete(200, 4_000)); + } + #[test] fn busy_exact_state_lock_skips_touch_without_waiting() { let cache = Arc::new(Mutex::new(TestRadix::new())); diff --git a/crates/skippy-server/src/kv_integration/mod.rs b/crates/skippy-server/src/kv_integration/mod.rs index 11a4b97929..b0762afb67 100644 --- a/crates/skippy-server/src/kv_integration/mod.rs +++ b/crates/skippy-server/src/kv_integration/mod.rs @@ -515,7 +515,9 @@ impl KvStageIntegration { self.payload .is_exact_state() .then_some(self.payload) - .or(self.durable_payload) + .or(self + .durable_payload + .filter(|payload| payload.is_exact_state())) } pub fn should_lookup(&self) -> bool { From c9bb7c759522fd06ff88c5af3a0af186c5ec3afb Mon Sep 17 00:00:00 2001 From: scama Date: Thu, 10 Sep 2026 06:25:59 +1000 Subject: [PATCH 08/41] fix(skippy): preserve durable restart checkpoints --- crates/skippy-cache/src/l3.rs | 288 +++++++++-- crates/skippy-cache/src/l3/packed.rs | 479 ++++++++++++++++++ crates/skippy-cache/src/l3/tests.rs | 97 ++++ crates/skippy-cache/src/tier.rs | 42 +- .../src/kv_integration/config.rs | 8 +- .../skippy-server/src/kv_integration/mod.rs | 98 +++- 6 files changed, 932 insertions(+), 80 deletions(-) create mode 100644 crates/skippy-cache/src/l3/packed.rs diff --git a/crates/skippy-cache/src/l3.rs b/crates/skippy-cache/src/l3.rs index 50a1de7ea0..0e0d521322 100644 --- a/crates/skippy-cache/src/l3.rs +++ b/crates/skippy-cache/src/l3.rs @@ -14,7 +14,7 @@ //! is present and the assembled payload digest matches. Partial state can //! never be loaded — there is nothing to load until commit. //! - **Idempotency**: putting a segment that already exists is a no-op; -//! concurrent writers of the same bytes converge on one file via +//! concurrent writers of the same bytes converge on one object via //! temp-file + atomic rename. //! - **Capped budget**: `enforce_budget` evicts oldest manifests first (the //! newest is never evicted) and garbage-collects unreferenced segments. @@ -34,6 +34,9 @@ use serde::{Deserialize, Serialize}; use crate::fsinfo; +mod packed; +use packed::{PACK_DIR, PACK_INDEX_DIR, PackedReadRequest, PackedSegmentStore}; + const SEGMENT_DIR: &str = "segments"; const MANIFEST_DIR: &str = "manifests"; const PREFIX_INDEX_DIR: &str = "prefixes"; @@ -433,6 +436,7 @@ pub struct HandoffSegmentStore { /// them. Left unprotected this fails as "manifest references missing /// segment" under exactly the pressure the cache is for. inflight_segments: Mutex>, + packed: PackedSegmentStore, } pub fn segment_digest(bytes: &[u8]) -> String { @@ -495,7 +499,13 @@ impl HandoffSegmentStore { root.display() ); } - for directory in [SEGMENT_DIR, MANIFEST_DIR, PREFIX_INDEX_DIR] { + for directory in [ + SEGMENT_DIR, + MANIFEST_DIR, + PREFIX_INDEX_DIR, + PACK_DIR, + PACK_INDEX_DIR, + ] { let path = root.join(directory); fsinfo::refuse_symlinked_descendant(&root, &path)?; fs::create_dir_all(&path).with_context(|| { @@ -505,6 +515,7 @@ impl HandoffSegmentStore { } fsinfo::restrict_to_owner(&root, 0o700)?; let root_lock = acquire_root_lock(&root)?; + let packed = PackedSegmentStore::open(&root)?; Ok(Self { root, limits: RwLock::new(limits), @@ -519,6 +530,7 @@ impl HandoffSegmentStore { evicted_manifests: AtomicU64::new(0), quarantined_objects: AtomicU64::new(0), inflight_segments: Mutex::new(std::collections::BTreeMap::new()), + packed, }) } @@ -559,7 +571,13 @@ impl HandoffSegmentStore { /// the manager exposes this root to any stage. pub fn reconcile_startup(&self) -> Result { let mut report = StoreReconciliation::default(); - for directory in [SEGMENT_DIR, MANIFEST_DIR, PREFIX_INDEX_DIR] { + for directory in [ + SEGMENT_DIR, + MANIFEST_DIR, + PREFIX_INDEX_DIR, + PACK_DIR, + PACK_INDEX_DIR, + ] { report.removed_temporary_files = report .removed_temporary_files .saturating_add(remove_temporary_files(&self.root.join(directory))?); @@ -571,6 +589,7 @@ impl HandoffSegmentStore { report.quarantined_manifests += 1; } } + self.rebuild_packed_index()?; report.removed_prefix_links = self.remove_dangling_prefix_links()?; report.removed_orphan_bytes = self.collect_unreferenced_segments()?; Ok(report) @@ -585,15 +604,15 @@ impl HandoffSegmentStore { ); } let mut expected_offset = 0u64; - for (position, segment) in manifest.segments.iter().enumerate() { + let locations = self + .packed + .load_manifest_index(key, manifest.segments.len())?; + for (position, (segment, location)) in manifest.segments.iter().zip(locations).enumerate() { if segment.index as usize != position || segment.offset != expected_offset { bail!("manifest {key} has invalid segment ordering"); } - let metadata = fs::metadata(self.segment_path(&segment.digest)) + self.validate_segment_ref(segment, location.as_ref()) .with_context(|| format!("manifest {key} references a missing segment"))?; - if metadata.len() != segment.bytes { - bail!("manifest {key} references a truncated segment"); - } expected_offset = expected_offset .checked_add(segment.bytes) .context("manifest segment offsets overflow")?; @@ -630,6 +649,38 @@ impl HandoffSegmentStore { self.root.join(SEGMENT_DIR).join(format!("{digest}.seg")) } + fn validate_segment_ref( + &self, + segment: &HandoffSegmentRef, + location: Option<&packed::PackedSegmentLocation>, + ) -> Result<()> { + if let Some(location) = location { + return self.packed.validate(location, segment.bytes); + } + let metadata = fs::metadata(self.segment_path(&segment.digest))?; + if metadata.len() != segment.bytes { + bail!("segment {} is truncated", segment.digest); + } + Ok(()) + } + + fn rebuild_packed_index(&self) -> Result<()> { + let mut entries = Vec::new(); + for key in self.list_manifests()? { + let Ok(manifest) = self.load_manifest(&key) else { + continue; + }; + let locations = self + .packed + .load_manifest_index(&key, manifest.segments.len())?; + entries.extend(manifest.segments.into_iter().zip(locations).filter_map( + |(segment, location)| location.map(|location| (segment.digest, location)), + )); + } + self.packed.rebuild(entries); + Ok(()) + } + fn manifest_path(&self, payload_digest: &str) -> PathBuf { self.root .join(MANIFEST_DIR) @@ -824,12 +875,75 @@ impl HandoffSegmentStore { })) } + /// Store one spill's logical segments in one immutable physical pack. + pub fn try_put_segments<'store>( + &'store self, + segments: &[&[u8]], + ) -> Result>, WriteRefusal>> { + let digests = segments + .iter() + .map(|bytes| segment_digest(bytes)) + .collect::>(); + let holds = digests + .iter() + .map(|digest| self.hold_segment(digest)) + .collect::>(); + let inputs = digests + .iter() + .zip(segments) + .map(|(digest, bytes)| (digest.as_str(), *bytes)) + .collect::>(); + let estimated = self.packed.estimated_new_bytes(&inputs); + let reservation = match self.reserve(estimated)? { + Ok(reservation) => reservation, + Err(refusal) => return Ok(Err(refusal)), + }; + let (packed, new_bytes) = match self.packed.write_batch(&inputs) { + Ok(result) => result, + Err(error) => { + self.invalidate_usage(); + return Err(error); + } + }; + self.add_usage_bytes(new_bytes); + drop(reservation); + Ok(Ok(digests + .into_iter() + .zip(holds) + .zip(packed) + .zip(segments) + .map(|(((digest, hold), packed), bytes)| StoredSegment { + digest, + put: SegmentPut { + new: packed.new, + bytes: bytes.len() as u64, + }, + _hold: hold, + }) + .collect())) + } + pub fn has_segment(&self, digest: &str) -> bool { - self.segment_path(digest).exists() + self.segment_path(digest).exists() || self.packed.location(digest).is_some() } /// Read one segment, verifying its content digest. pub fn read_segment(&self, digest: &str) -> Result> { + if let Some(location) = self.packed.location(digest) { + let requests = [PackedReadRequest { + digest, + bytes: location.bytes, + location: &location, + }]; + return match self.packed.read_many(&requests) { + Ok(mut bytes) => bytes.pop().context("packed segment read was empty"), + Err(failure) => { + let path = self.packed.pack_path(&failure.pack_digest); + let _ = self.quarantine(&path); + Err(failure.error) + } + }; + } let path = self.segment_path(digest); let bytes = fs::read(&path).with_context(|| format!("failed to read segment {digest}"))?; if segment_digest(&bytes) != digest { @@ -893,7 +1007,13 @@ impl HandoffSegmentStore { bail!("manifest has no payload digest"); } let mut expected_offset = 0u64; - for (position, segment) in manifest.segments.iter().enumerate() { + let locations = manifest + .segments + .iter() + .map(|segment| self.packed.location(&segment.digest)) + .collect::>(); + for (position, (segment, location)) in manifest.segments.iter().zip(&locations).enumerate() + { if segment.index as usize != position { bail!( "manifest segment order broken: index {} at position {position}", @@ -907,21 +1027,13 @@ impl HandoffSegmentStore { segment.offset ); } - let path = self.segment_path(&segment.digest); - let metadata = fs::metadata(&path).with_context(|| { - format!( - "manifest references missing segment {} ({})", - segment.index, segment.digest - ) - })?; - if metadata.len() != segment.bytes { - bail!( - "segment {} has {} bytes on disk but manifest records {}", - segment.digest, - metadata.len(), - segment.bytes - ); - } + self.validate_segment_ref(segment, location.as_ref()) + .with_context(|| { + format!( + "manifest references missing segment {} ({})", + segment.index, segment.digest + ) + })?; expected_offset = expected_offset .checked_add(segment.bytes) .context("manifest offsets overflow")?; @@ -941,6 +1053,14 @@ impl HandoffSegmentStore { // build its reference map. Indentation is pure cost on a file nobody // reads by hand. let serialized = serde_json::to_vec(manifest).context("failed to serialize manifest")?; + let segment_digests = manifest + .segments + .iter() + .map(|segment| segment.digest.clone()) + .collect::>(); + let packed_index = self + .packed + .encode_manifest_index(&manifest.payload_digest, &segment_digests)?; // The manifest is what makes the segments loadable, so it is pinned // while it lands: eviction triggered by its own admission check must // not remove the entry being committed. @@ -956,8 +1076,23 @@ impl HandoffSegmentStore { } })?; let growth_bytes = (serialized.len() as u64).saturating_sub(replaced_bytes); - match self.reserve_write(serialized.len() as u64, growth_bytes)? { + let packed_index_path = self.packed.index_path(&manifest.payload_digest); + let replaced_index_bytes = fs::metadata(&packed_index_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + let index_bytes = packed_index.as_ref().map_or(0, |bytes| bytes.len() as u64); + let write_bytes = (serialized.len() as u64).saturating_add(index_bytes); + let total_growth = + growth_bytes.saturating_add(index_bytes.saturating_sub(replaced_index_bytes)); + match self.reserve_write(write_bytes, total_growth)? { Ok(_reservation) => { + if let Some(index) = &packed_index { + self.packed + .publish_manifest_index(&manifest.payload_digest, index)?; + } else { + self.packed + .remove_manifest_index(&manifest.payload_digest)?; + } write_atomically(&manifest_path, &serialized)?; fsinfo::restrict_to_owner(&manifest_path, 0o600)?; } @@ -1008,7 +1143,30 @@ impl HandoffSegmentStore { pub fn assemble(&self, manifest: &HandoffManifest) -> Result> { let total = usize::try_from(manifest.total_bytes).context("payload exceeds usize")?; let mut payload = Vec::with_capacity(total); - for segment in &manifest.segments { + let locations = self + .packed + .load_manifest_index(&manifest.payload_digest, manifest.segments.len())?; + let packed_requests = manifest + .segments + .iter() + .zip(&locations) + .filter_map(|(segment, location)| { + location.as_ref().map(|location| PackedReadRequest { + digest: &segment.digest, + bytes: segment.bytes, + location, + }) + }) + .collect::>(); + let mut packed_bytes = match self.packed.read_many(&packed_requests) { + Ok(bytes) => bytes.into_iter(), + Err(failure) => { + let path = self.packed.pack_path(&failure.pack_digest); + let _ = self.quarantine(&path); + return Err(failure.error); + } + }; + for (segment, location) in manifest.segments.iter().zip(locations) { if segment.offset != payload.len() as u64 { bail!( "segment {} offset {} does not match assembled length {}", @@ -1017,7 +1175,12 @@ impl HandoffSegmentStore { payload.len() ); } - payload.extend_from_slice(&self.read_segment(&segment.digest)?); + let bytes = if location.is_some() { + packed_bytes.next().context("missing packed segment read")? + } else { + self.read_segment(&segment.digest)? + }; + payload.extend_from_slice(&bytes); } if payload.len() != total { bail!( @@ -1032,7 +1195,8 @@ impl HandoffSegmentStore { } pub fn segment_footprint_bytes(&self) -> Result { - directory_bytes(&self.root.join(SEGMENT_DIR)) + Ok(directory_bytes(&self.root.join(SEGMENT_DIR))? + .saturating_add(self.packed.footprint_bytes()?)) } /// Every byte the store manages: committed segments, the manifests that @@ -1054,6 +1218,8 @@ impl HandoffSegmentStore { /// Stat every managed file and adopt the result as the running total. fn rescan_usage_bytes(&self) -> Result { let mut total = directory_bytes(&self.root.join(SEGMENT_DIR))?; + total = total.saturating_add(self.packed.footprint_bytes()?); + total = total.saturating_add(self.packed.index_footprint_bytes()?); total = total.saturating_add(directory_bytes(&self.root.join(MANIFEST_DIR))?); total = total.saturating_add(directory_bytes_recursive( &self.root.join(PREFIX_INDEX_DIR), @@ -1261,18 +1427,30 @@ impl HandoffSegmentStore { let Ok(manifest) = self.load_manifest(key) else { continue; }; - for segment in &manifest.segments { - let entry = reference_counts - .entry(segment.digest.clone()) - .or_insert((0, segment.bytes)); + let locations = self + .packed + .load_manifest_index(key, manifest.segments.len())?; + for (segment, location) in manifest.segments.iter().zip(&locations) { + let (object, bytes) = location.as_ref().map_or_else( + || (segment.digest.clone(), segment.bytes), + |location| { + ( + format!("pack:{}", location.pack_digest), + fs::metadata(self.packed.pack_path(&location.pack_digest)) + .map(|metadata| metadata.len()) + .unwrap_or(0), + ) + }, + ); + let entry = reference_counts.entry(object).or_insert((0, bytes)); entry.0 += 1; } - manifests.push(manifest); + manifests.push((manifest, locations)); } let mut freeable = usage_before; let mut evicted_any = false; while freeable > target_bytes { - let Some(position) = manifests.iter().rposition(|manifest| { + let Some(position) = manifests.iter().rposition(|(manifest, _)| { !self.is_pinned(&manifest.payload_digest) && model_identity.is_none_or(|identity| manifest.model_identity == identity) }) else { @@ -1280,7 +1458,7 @@ impl HandoffSegmentStore { // tearing state out from under a live operation is not. break; }; - let evicted = manifests.remove(position); + let (evicted, locations) = manifests.remove(position); let manifest_path = self.manifest_path(&evicted.payload_digest); let manifest_bytes = fs::metadata(&manifest_path) .map(|metadata| metadata.len()) @@ -1288,10 +1466,17 @@ impl HandoffSegmentStore { self.invalidate_usage(); fs::remove_file(&manifest_path) .with_context(|| format!("failed to evict manifest {}", evicted.payload_digest))?; + let index_bytes = self.packed.remove_manifest_index(&evicted.payload_digest)?; self.evicted_manifests.fetch_add(1, Ordering::Relaxed); - freeable = freeable.saturating_sub(manifest_bytes); - for segment in &evicted.segments { - if let Some(entry) = reference_counts.get_mut(&segment.digest) { + freeable = freeable + .saturating_sub(manifest_bytes) + .saturating_sub(index_bytes); + for (segment, location) in evicted.segments.iter().zip(locations) { + let object = location.map_or_else( + || segment.digest.clone(), + |location| format!("pack:{}", location.pack_digest), + ); + if let Some(entry) = reference_counts.get_mut(&object) { entry.0 = entry.0.saturating_sub(1); if entry.0 == 0 { freeable = freeable.saturating_sub(entry.1); @@ -1358,6 +1543,7 @@ impl HandoffSegmentStore { return Err(error).with_context(|| format!("failed to clear manifest {key}")); } } + self.packed.remove_manifest_index(&key)?; } self.remove_dangling_prefix_links()?; self.collect_unreferenced_segments()?; @@ -1382,14 +1568,22 @@ impl HandoffSegmentStore { // Files are about to be removed or rewritten in bulk. self.invalidate_usage(); let mut referenced = std::collections::HashSet::new(); - for key in self.list_manifests()? { - if let Ok(manifest) = self.load_manifest(&key) { + let manifest_keys = self.list_manifests()?; + for key in &manifest_keys { + if let Ok(manifest) = self.load_manifest(key) { for segment in manifest.segments { referenced.insert(segment.digest); } } } let mut freed = 0u64; + let held = self + .inflight_segments + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .keys() + .cloned() + .collect::>(); for entry in fs::read_dir(self.root.join(SEGMENT_DIR))? { let entry = entry?; let path = entry.path(); @@ -1399,17 +1593,17 @@ impl HandoffSegmentStore { else { continue; }; - let held = self - .inflight_segments - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .contains_key(&stem); - if !referenced.contains(&stem) && !held { + if !referenced.contains(&stem) && !held.contains(&stem) { freed = freed.saturating_add(entry.metadata()?.len()); fs::remove_file(&path) .with_context(|| format!("failed to collect segment {stem}"))?; } } + let manifests = manifest_keys + .into_iter() + .collect::>(); + freed = freed.saturating_add(self.packed.remove_orphan_indexes(&manifests)?); + freed = freed.saturating_add(self.packed.remove_orphan_packs(&referenced, &held)?); Ok(freed) } } diff --git a/crates/skippy-cache/src/l3/packed.rs b/crates/skippy-cache/src/l3/packed.rs new file mode 100644 index 0000000000..b91a82b28a --- /dev/null +++ b/crates/skippy-cache/src/l3/packed.rs @@ -0,0 +1,479 @@ +//! Append-only physical storage for logical L3 segments. +//! +//! One spill publishes at most one immutable pack. A local sidecar maps the +//! manifest's portable segment digests to pack offsets, keeping the handoff +//! manifest independent of this node's physical layout. + +use std::{ + collections::{HashMap, HashSet}, + fs::{self, File}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::{Mutex, RwLock}, +}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use super::{segment_digest, tempfile_in, write_atomically}; +use crate::fsinfo; + +pub(super) const PACK_DIR: &str = "packs"; +pub(super) const PACK_INDEX_DIR: &str = "pack-indexes"; +const PACK_INDEX_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub(super) struct PackedSegmentLocation { + pub pack_digest: String, + pub offset: u64, + pub bytes: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PackedManifestIndex { + version: u32, + payload_digest: String, + segments: Vec>, +} + +#[derive(Debug)] +pub(super) struct PackedStoredSegment { + pub new: bool, +} + +#[derive(Debug)] +pub(super) struct PackedReadError { + pub pack_digest: String, + pub error: anyhow::Error, +} + +#[derive(Debug)] +pub(super) struct PackedReadRequest<'a> { + pub digest: &'a str, + pub bytes: u64, + pub location: &'a PackedSegmentLocation, +} + +#[derive(Debug)] +pub(super) struct PackedSegmentStore { + directory: PathBuf, + index_directory: PathBuf, + locations: RwLock>, + /// Serializes immutable pack publication with orphan collection. + mutation: Mutex<()>, +} + +impl PackedSegmentStore { + pub(super) fn open(root: &Path) -> Result { + let directory = root.join(PACK_DIR); + let index_directory = root.join(PACK_INDEX_DIR); + for path in [&directory, &index_directory] { + fsinfo::refuse_symlinked_descendant(root, path)?; + fs::create_dir_all(path) + .with_context(|| format!("failed to create {}", path.display()))?; + fsinfo::restrict_to_owner(path, 0o700)?; + } + Ok(Self { + directory, + index_directory, + locations: RwLock::new(HashMap::new()), + mutation: Mutex::new(()), + }) + } + + pub(super) fn pack_path(&self, pack_digest: &str) -> PathBuf { + self.directory.join(format!("{pack_digest}.pack")) + } + + pub(super) fn index_path(&self, payload_digest: &str) -> PathBuf { + self.index_directory.join(format!("{payload_digest}.json")) + } + + pub(super) fn estimated_new_bytes(&self, segments: &[(&str, &[u8])]) -> u64 { + let locations = self + .locations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut unique = HashSet::new(); + segments + .iter() + .filter(|(digest, _)| unique.insert(*digest)) + .filter(|(digest, _)| { + locations + .get(*digest) + .is_none_or(|location| !self.location_is_present(location)) + }) + .map(|(_, bytes)| bytes.len() as u64) + .fold(0u64, u64::saturating_add) + } + + /// Publish all missing logical segments as one immutable pack. + pub(super) fn write_batch( + &self, + segments: &[(&str, &[u8])], + ) -> Result<(Vec, u64)> { + let _mutation = self + .mutation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let existing = self + .locations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let mut planned = HashMap::::new(); + let mut pack_segments = Vec::new(); + let mut pack_hasher = blake3::Hasher::new(); + let mut candidate_bytes = 0u64; + let mut new_digests = HashSet::new(); + + for (digest, bytes) in segments { + if existing + .get(*digest) + .is_some_and(|location| self.location_is_present(location)) + || planned.contains_key(*digest) + { + continue; + } + let offset = candidate_bytes; + candidate_bytes = candidate_bytes + .checked_add(bytes.len() as u64) + .context("packed object size overflows u64")?; + pack_hasher.update(bytes); + pack_segments.push(*bytes); + planned.insert( + (*digest).to_string(), + PackedSegmentLocation { + pack_digest: String::new(), + offset, + bytes: bytes.len() as u64, + }, + ); + new_digests.insert((*digest).to_string()); + } + + let mut new_bytes = 0u64; + if !pack_segments.is_empty() { + let pack_digest = pack_hasher.finalize().to_hex().to_string(); + let path = self.pack_path(&pack_digest); + if path.exists() { + let actual = fs::metadata(&path)?.len(); + if actual != candidate_bytes { + bail!( + "packed object {pack_digest} has {actual} bytes but the write has {candidate_bytes}" + ); + } + } else { + write_pack_atomically(&path, &pack_segments)?; + fsinfo::restrict_to_owner(&path, 0o600)?; + new_bytes = candidate_bytes; + } + for location in planned.values_mut() { + location.pack_digest.clone_from(&pack_digest); + } + } + + let mut locations = self + .locations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for (digest, location) in &planned { + locations.insert(digest.clone(), location.clone()); + } + let published_new_pack = new_bytes > 0; + let mut reported_new = HashSet::new(); + let stored = segments + .iter() + .map(|(digest, _)| PackedStoredSegment { + new: published_new_pack + && new_digests.contains(*digest) + && reported_new.insert(*digest), + }) + .collect(); + Ok((stored, new_bytes)) + } + + pub(super) fn location(&self, digest: &str) -> Option { + self.locations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(digest) + .filter(|location| self.location_is_present(location)) + .cloned() + } + + pub(super) fn encode_manifest_index( + &self, + payload_digest: &str, + segment_digests: &[String], + ) -> Result>> { + let segments = segment_digests + .iter() + .map(|digest| self.location(digest)) + .collect::>(); + if segments.iter().all(Option::is_none) { + return Ok(None); + } + serde_json::to_vec(&PackedManifestIndex { + version: PACK_INDEX_VERSION, + payload_digest: payload_digest.to_string(), + segments, + }) + .context("failed to serialize packed manifest index") + .map(Some) + } + + pub(super) fn publish_manifest_index( + &self, + payload_digest: &str, + encoded: &[u8], + ) -> Result<()> { + let path = self.index_path(payload_digest); + write_atomically(&path, encoded)?; + fsinfo::restrict_to_owner(&path, 0o600) + } + + pub(super) fn load_manifest_index( + &self, + payload_digest: &str, + segment_count: usize, + ) -> Result>> { + let path = self.index_path(payload_digest); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(vec![None; segment_count]); + } + Err(error) => return Err(error.into()), + }; + let index: PackedManifestIndex = + serde_json::from_slice(&bytes).context("malformed packed manifest index")?; + if index.version != PACK_INDEX_VERSION + || index.payload_digest != payload_digest + || index.segments.len() != segment_count + { + bail!("packed manifest index does not match manifest {payload_digest}"); + } + for location in index.segments.iter().flatten() { + if !is_digest(&location.pack_digest) || location.bytes == 0 { + bail!("packed manifest index contains an invalid location"); + } + location + .offset + .checked_add(location.bytes) + .context("packed manifest index range overflows")?; + } + Ok(index.segments) + } + + pub(super) fn remove_manifest_index(&self, payload_digest: &str) -> Result { + let path = self.index_path(payload_digest); + let bytes = fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + match fs::remove_file(&path) { + Ok(()) => Ok(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(error) => Err(error.into()), + } + } + + pub(super) fn validate(&self, location: &PackedSegmentLocation, bytes: u64) -> Result<()> { + if location.bytes != bytes { + bail!( + "packed index records {} bytes but manifest records {bytes}", + location.bytes + ); + } + let pack_bytes = fs::metadata(self.pack_path(&location.pack_digest)) + .with_context(|| format!("missing pack {}", location.pack_digest))? + .len(); + let end = location + .offset + .checked_add(bytes) + .context("packed segment range overflows")?; + if end > pack_bytes { + bail!( + "pack {} has {pack_bytes} bytes but segment range ends at {end}", + location.pack_digest + ); + } + Ok(()) + } + + /// Read requests in logical order while opening each physical pack once. + pub(super) fn read_many( + &self, + requests: &[PackedReadRequest<'_>], + ) -> Result>, PackedReadError> { + let mut files = HashMap::::new(); + let mut output = Vec::with_capacity(requests.len()); + for request in requests { + let result = (|| -> Result> { + let file = match files.entry(request.location.pack_digest.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::hash_map::Entry::Vacant(entry) => { + let file = File::open(self.pack_path(&request.location.pack_digest)) + .with_context(|| { + format!("failed to open pack {}", request.location.pack_digest) + })?; + entry.insert(file) + } + }; + file.seek(SeekFrom::Start(request.location.offset))?; + let len = usize::try_from(request.bytes).context("segment exceeds usize")?; + let mut bytes = vec![0u8; len]; + file.read_exact(&mut bytes)?; + if segment_digest(&bytes) != request.digest { + bail!( + "packed segment {} failed digest verification", + request.digest + ); + } + Ok(bytes) + })(); + match result { + Ok(bytes) => output.push(bytes), + Err(error) => { + return Err(PackedReadError { + pack_digest: request.location.pack_digest.clone(), + error, + }); + } + } + } + Ok(output) + } + + pub(super) fn rebuild( + &self, + entries: impl IntoIterator, + ) { + let mut locations = self + .locations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + locations.clear(); + locations.extend(entries); + } + + pub(super) fn remove_orphan_packs( + &self, + referenced_segments: &HashSet, + held_segments: &HashSet, + ) -> Result { + let _mutation = self + .mutation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let locations = self + .locations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let protected = locations + .iter() + .filter(|(digest, _)| { + referenced_segments.contains(*digest) || held_segments.contains(*digest) + }) + .map(|(_, location)| location.pack_digest.clone()) + .collect::>(); + let mut freed = 0u64; + for entry in fs::read_dir(&self.directory)? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "pack") { + continue; + } + let Some(pack_digest) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if protected.contains(pack_digest) { + continue; + } + freed = freed.saturating_add(entry.metadata()?.len()); + fs::remove_file(&path) + .with_context(|| format!("failed to collect pack {pack_digest}"))?; + } + Ok(freed) + } + + pub(super) fn remove_orphan_indexes(&self, manifests: &HashSet) -> Result { + let mut freed = 0u64; + for entry in fs::read_dir(&self.index_directory)? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "json") { + continue; + } + let Some(payload_digest) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if manifests.contains(payload_digest) { + continue; + } + freed = freed.saturating_add(entry.metadata()?.len()); + fs::remove_file(&path)?; + } + Ok(freed) + } + + pub(super) fn footprint_bytes(&self) -> Result { + directory_bytes(&self.directory) + } + + pub(super) fn index_footprint_bytes(&self) -> Result { + directory_bytes(&self.index_directory) + } + + fn location_is_present(&self, location: &PackedSegmentLocation) -> bool { + self.pack_path(&location.pack_digest) + .metadata() + .is_ok_and(|metadata| location.offset < metadata.len()) + } +} + +fn directory_bytes(directory: &Path) -> Result { + let mut total = 0u64; + for entry in fs::read_dir(directory)? { + let entry = entry?; + if entry.file_type()?.is_file() { + total = total.saturating_add(entry.metadata()?.len()); + } + } + Ok(total) +} + +fn write_pack_atomically(path: &Path, segments: &[&[u8]]) -> Result<()> { + let directory = path.parent().context("pack path has no parent directory")?; + let (temp_path, mut temp_file) = tempfile_in(directory)?; + let write_result = (|| -> Result<()> { + for segment in segments { + temp_file + .write_all(segment) + .with_context(|| format!("failed to write {}", temp_path.display()))?; + } + temp_file + .sync_all() + .with_context(|| format!("failed to sync {}", temp_path.display())) + })(); + drop(temp_file); + let publish_result = write_result.and_then(|()| fsinfo::replace_file(&temp_path, path)); + if let Err(error) = publish_result { + return match fs::remove_file(&temp_path) { + Ok(()) => Err(error), + Err(cleanup) if cleanup.kind() == std::io::ErrorKind::NotFound => Err(error), + Err(cleanup) => Err(error.context(format!( + "also failed to remove temporary pack {}: {cleanup}", + temp_path.display() + ))), + }; + } + Ok(()) +} + +fn is_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} diff --git a/crates/skippy-cache/src/l3/tests.rs b/crates/skippy-cache/src/l3/tests.rs index f46dc18c39..1bb7eada8c 100644 --- a/crates/skippy-cache/src/l3/tests.rs +++ b/crates/skippy-cache/src/l3/tests.rs @@ -50,6 +50,36 @@ fn commit_payload( manifest } +fn commit_packed_payload( + store: &HandoffSegmentStore, + payload: &[u8], + segment_bytes: usize, +) -> HandoffManifest { + let chunks = payload.chunks(segment_bytes).collect::>(); + let held = store + .try_put_segments(&chunks) + .expect("packed put") + .expect("packed put admitted"); + let mut manifest = HandoffManifest::new("blake3:test".to_string(), "full-state".into()); + let mut offset = 0u64; + for (index, stored) in held.iter().enumerate() { + let bytes = chunks[index].len() as u64; + manifest.segments.push(HandoffSegmentRef { + index: index as u32, + offset, + bytes, + digest: stored.digest.clone(), + meta_json: None, + }); + offset += bytes; + } + manifest.total_bytes = payload.len() as u64; + manifest.payload_digest = segment_digest(payload); + store.commit(&manifest).expect("packed commit"); + drop(held); + manifest +} + fn temp_root(name: &str) -> PathBuf { let root = std::env::temp_dir() .join("skippy-l3-tests") @@ -70,6 +100,73 @@ fn roundtrip_assembles_identical_payload() { assert_eq!(store.assemble(&loaded).expect("assemble"), payload); } +#[test] +fn packed_roundtrip_uses_one_physical_file_and_survives_reopen() { + let root = temp_root("packed-roundtrip"); + let payload: Vec = (0..100_000u32).map(|value| value as u8).collect(); + let manifest = { + let store = store(&root, 0); + let manifest = commit_packed_payload(&store, &payload, 4096); + assert_eq!(fs::read_dir(root.join(PACK_DIR)).unwrap().count(), 1); + assert_eq!(fs::read_dir(root.join(SEGMENT_DIR)).unwrap().count(), 0); + let manifest_json = fs::read_to_string(store.manifest_path(&manifest.payload_digest)) + .expect("read portable manifest"); + assert!(!manifest_json.contains("pack_digest")); + assert_eq!(store.assemble(&manifest).expect("assemble"), payload); + manifest + }; + + let reopened = store(&root, 0); + reopened + .reconcile_startup() + .expect("reconcile packed store"); + let loaded = reopened + .load_manifest(&manifest.payload_digest) + .expect("load packed manifest after restart"); + assert_eq!(reopened.assemble(&loaded).expect("assemble"), payload); +} + +#[test] +fn corrupt_pack_is_quarantined_and_never_served() { + let root = temp_root("packed-corruption"); + let store = store(&root, 0); + let payload: Vec = (0..32_000u32).map(|value| value as u8).collect(); + let manifest = commit_packed_payload(&store, &payload, 4096); + let pack = fs::read_dir(root.join(PACK_DIR)) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let mut bytes = fs::read(&pack).unwrap(); + bytes[0] ^= 0xff; + fs::write(&pack, bytes).unwrap(); + + assert!(store.assemble(&manifest).is_err()); + assert!(!pack.exists()); + assert!(root.join(QUARANTINE_DIR).exists()); +} + +#[test] +fn uncommitted_pack_is_collected_after_holds_release() { + let root = temp_root("packed-orphan"); + let store = store(&root, 0); + let payload = (0..16_000) + .map(|index| (index / 1024) as u8) + .collect::>(); + let chunks = payload.chunks(1024).collect::>(); + let held = store + .try_put_segments(&chunks) + .unwrap() + .expect("packed put admitted"); + assert_eq!(store.collect_unreferenced_segments().unwrap(), 0); + drop(held); + assert_eq!( + store.collect_unreferenced_segments().unwrap(), + payload.len() as u64 + ); +} + #[test] fn cached_usage_never_diverges_from_a_full_scan() { // The incremental total exists to keep `reserve` off an O(files) scan diff --git a/crates/skippy-cache/src/tier.rs b/crates/skippy-cache/src/tier.rs index 4d6b49227a..1985610e0b 100644 --- a/crates/skippy-cache/src/tier.rs +++ b/crates/skippy-cache/src/tier.rs @@ -300,27 +300,35 @@ impl L3Tier { cuts } }; + let segment_slices = cuts + .iter() + .map(|(offset, len, _)| { + let start = usize::try_from(*offset).context("segment offset exceeds usize")?; + let end = start + .checked_add(usize::try_from(*len).context("segment length exceeds usize")?) + .context("segment range overflows")?; + Ok(&wire[start..end]) + }) + .collect::>>()?; + let stored_segments = match self.store().try_put_segments(&segment_slices) { + Ok(Ok(stored)) => stored, + Ok(Err(refusal)) => { + self.manager.record_write_refusal(refusal); + bail!("cannot store packed segments: {}", refusal.reason()); + } + Err(error) => { + self.manager.record_storage_error(); + return Err(error); + } + }; let mut new_bytes = 0u64; // Held until after the commit below: until the manifest names them // these segments are unreferenced, and an eviction triggered by // another writer would collect them mid-build. - let mut held = Vec::with_capacity(manifest.segments.capacity()); - for (index, (offset, len, label)) in cuts.into_iter().enumerate() { - let start = usize::try_from(offset).context("segment offset exceeds usize")?; - let end = start - .checked_add(usize::try_from(len).context("segment length exceeds usize")?) - .context("segment range overflows")?; - let stored = match self.store().try_put_segment(&wire[start..end]) { - Ok(Ok(stored)) => stored, - Ok(Err(refusal)) => { - self.manager.record_write_refusal(refusal); - bail!("cannot store segment: {}", refusal.reason()); - } - Err(error) => { - self.manager.record_storage_error(); - return Err(error); - } - }; + let mut held = Vec::with_capacity(stored_segments.len()); + for ((index, (offset, len, label)), stored) in + cuts.into_iter().enumerate().zip(stored_segments) + { if stored.put.new { new_bytes = new_bytes.saturating_add(stored.put.bytes); } diff --git a/crates/skippy-server/src/kv_integration/config.rs b/crates/skippy-server/src/kv_integration/config.rs index 75f0efba06..c174ee77d2 100644 --- a/crates/skippy-server/src/kv_integration/config.rs +++ b/crates/skippy-server/src/kv_integration/config.rs @@ -169,7 +169,7 @@ impl KvStageIntegration { let worker_exact_state_record_worker_healthy = exact_state_record_worker_healthy.clone(); let exact_state_record_worker_panics = Arc::new(std::sync::atomic::AtomicU64::new(0)); let worker_exact_state_record_worker_panics = exact_state_record_worker_panics.clone(); - std::thread::Builder::new() + let exact_state_record_task = std::thread::Builder::new() .name(format!("skippy-exact-cache-{}", config.stage_id)) .spawn(move || { while let Ok(pending) = exact_state_record_rx.recv() { @@ -204,6 +204,10 @@ impl KvStageIntegration { } } })?; + let exact_state_record_worker = Arc::new(super::ExactStateRecordWorker::new( + exact_state_record_tx, + exact_state_record_task, + )); Ok(Some(Self { mode, payload, @@ -222,7 +226,7 @@ impl KvStageIntegration { exact_blobs, exact_max_entries, exact_byte_limits, - exact_state_record_tx, + exact_state_record_worker, exact_state_records_queued, exact_state_records_dropped, exact_state_records_pending, diff --git a/crates/skippy-server/src/kv_integration/mod.rs b/crates/skippy-server/src/kv_integration/mod.rs index b0762afb67..bbffb5c299 100644 --- a/crates/skippy-server/src/kv_integration/mod.rs +++ b/crates/skippy-server/src/kv_integration/mod.rs @@ -5,6 +5,7 @@ use std::{ atomic::{AtomicBool, AtomicU64, AtomicUsize}, mpsc::{SyncSender, TrySendError}, }, + thread::JoinHandle, }; use anyhow::{Result, bail}; @@ -167,7 +168,7 @@ pub struct KvStageIntegration { pub(crate) exact_blobs: Arc>, pub(crate) exact_max_entries: usize, pub(crate) exact_byte_limits: ExactStateByteLimits, - pub(crate) exact_state_record_tx: SyncSender, + pub(crate) exact_state_record_worker: Arc, pub(crate) exact_state_records_queued: Arc, pub(crate) exact_state_records_dropped: Arc, pub(crate) exact_state_records_pending: Arc, @@ -227,6 +228,49 @@ pub(crate) struct PendingExactStateRecord { pub(crate) l3_fill_claim: Option, } +#[derive(Debug)] +pub(crate) struct ExactStateRecordWorker { + sender: Mutex>>, + task: Mutex>>, +} + +impl ExactStateRecordWorker { + pub(crate) fn new(sender: SyncSender, task: JoinHandle<()>) -> Self { + Self { + sender: Mutex::new(Some(sender)), + task: Mutex::new(Some(task)), + } + } + + fn with_sender( + &self, + use_sender: impl FnOnce(Option<&SyncSender>) -> T, + ) -> T { + let sender = self + .sender + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + use_sender(sender.as_ref()) + } +} + +impl Drop for ExactStateRecordWorker { + fn drop(&mut self) { + self.sender + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(task) = self + .task + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = task.join(); + } + } +} + #[derive(Debug, Clone)] pub(crate) struct RadixResidentEntry { pub(crate) page_id: String, @@ -596,17 +640,25 @@ impl KvStageIntegration { &self, pending: PendingExactStateRecord, ) -> ExactStateRecordAdmission { - enqueue_exact_state_record( - &self.exact_state_record_tx, - &self.inflight_records, - &self.exact_state_records_queued, - &self.exact_state_records_dropped, - &self.exact_state_records_pending, - &self.exact_state_record_queue_bytes, - EXACT_STATE_RECORD_QUEUE_BYTES, - &self.exact_state_record_worker_healthy, - pending, - ) + self.exact_state_record_worker.with_sender(|sender| { + let Some(sender) = sender else { + self.finish_record(&pending.page_id); + self.exact_state_records_dropped + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + return ExactStateRecordAdmission::WorkerStopped; + }; + enqueue_exact_state_record( + sender, + &self.inflight_records, + &self.exact_state_records_queued, + &self.exact_state_records_dropped, + &self.exact_state_records_pending, + &self.exact_state_record_queue_bytes, + EXACT_STATE_RECORD_QUEUE_BYTES, + &self.exact_state_record_worker_healthy, + pending, + ) + }) } pub async fn hello(&self) -> Result<()> { @@ -1013,8 +1065,8 @@ mod exact_state_record_queue_tests { use super::{ BTreeSet, EXACT_STATE_RECORD_CAPACITY, ExactStateExtra, ExactStateRecordAdmission, - PendingExactStateRecord, enqueue_exact_state_record, has_exact_state_record_capacity, - run_exact_state_record_job, + ExactStateRecordWorker, PendingExactStateRecord, enqueue_exact_state_record, + has_exact_state_record_capacity, run_exact_state_record_job, }; fn pending(page_id: &str) -> PendingExactStateRecord { @@ -1037,6 +1089,24 @@ mod exact_state_record_queue_tests { const CAP: u64 = 1024; + #[test] + fn final_worker_owner_drains_queued_records_before_drop_returns() { + let (sender, receiver) = sync_channel(2); + let completed = Arc::new(AtomicUsize::new(0)); + let worker_completed = completed.clone(); + let task = std::thread::spawn(move || { + while receiver.recv().is_ok() { + worker_completed.fetch_add(1, Ordering::Release); + } + }); + let worker = Arc::new(ExactStateRecordWorker::new(sender, task)); + worker.with_sender(|sender| sender.unwrap().send(pending("latest")).unwrap()); + + drop(worker); + + assert_eq!(completed.load(Ordering::Acquire), 1); + } + #[test] fn pending_capacity_signal_rejects_work_before_export() { let pending_count = AtomicUsize::new(0); From 25cdc0a64642de7a2e5d319b127fd54b7897aa4e Mon Sep 17 00:00:00 2001 From: scama Date: Thu, 10 Sep 2026 09:01:24 +1000 Subject: [PATCH 09/41] chore(ci): refresh console print ratchet --- tools/xtask/data/console_print_allowlist.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/xtask/data/console_print_allowlist.json b/tools/xtask/data/console_print_allowlist.json index 96b4bc69aa..7c3eafa525 100644 --- a/tools/xtask/data/console_print_allowlist.json +++ b/tools/xtask/data/console_print_allowlist.json @@ -3699,7 +3699,7 @@ ], "crates/skippy-cache/src/l3/tests.rs": [ { - "line": 508, + "line": 605, "macro_name": "println!" } ], From e03de612abaafc4753e57031c5be85fa925846b9 Mon Sep 17 00:00:00 2001 From: scama Date: Thu, 10 Sep 2026 09:16:42 +1000 Subject: [PATCH 10/41] perf(skippy): stream packed restore into payload --- crates/skippy-cache/src/l3.rs | 80 ++++++++++------- crates/skippy-cache/src/l3/packed.rs | 127 ++++++++++++++++++++++----- 2 files changed, 154 insertions(+), 53 deletions(-) diff --git a/crates/skippy-cache/src/l3.rs b/crates/skippy-cache/src/l3.rs index 0e0d521322..f30cdf35a6 100644 --- a/crates/skippy-cache/src/l3.rs +++ b/crates/skippy-cache/src/l3.rs @@ -934,9 +934,12 @@ impl HandoffSegmentStore { digest, bytes: location.bytes, location: &location, + output_offset: 0, }]; - return match self.packed.read_many(&requests) { - Ok(mut bytes) => bytes.pop().context("packed segment read was empty"), + let len = usize::try_from(location.bytes).context("segment exceeds usize")?; + let mut bytes = Vec::with_capacity(len); + return match self.packed.append_many(&requests, &mut bytes) { + Ok(()) => Ok(bytes), Err(failure) => { let path = self.packed.pack_path(&failure.pack_digest); let _ = self.quarantine(&path); @@ -1146,42 +1149,42 @@ impl HandoffSegmentStore { let locations = self .packed .load_manifest_index(&manifest.payload_digest, manifest.segments.len())?; - let packed_requests = manifest - .segments - .iter() - .zip(&locations) - .filter_map(|(segment, location)| { - location.as_ref().map(|location| PackedReadRequest { - digest: &segment.digest, - bytes: segment.bytes, - location, - }) - }) - .collect::>(); - let mut packed_bytes = match self.packed.read_many(&packed_requests) { - Ok(bytes) => bytes.into_iter(), - Err(failure) => { - let path = self.packed.pack_path(&failure.pack_digest); - let _ = self.quarantine(&path); - return Err(failure.error); - } - }; - for (segment, location) in manifest.segments.iter().zip(locations) { - if segment.offset != payload.len() as u64 { + let mut expected_offset = 0u64; + for segment in &manifest.segments { + if segment.offset != expected_offset { bail!( - "segment {} offset {} does not match assembled length {}", + "segment {} offset {} does not match assembled length {expected_offset}", segment.index, segment.offset, - payload.len() ); } - let bytes = if location.is_some() { - packed_bytes.next().context("missing packed segment read")? + expected_offset = expected_offset + .checked_add(segment.bytes) + .context("assembled payload size overflows")?; + } + if expected_offset != manifest.total_bytes { + bail!( + "assembled {expected_offset} bytes but manifest records {}", + manifest.total_bytes + ); + } + let mut packed_requests = Vec::new(); + for (segment, location) in manifest.segments.iter().zip(&locations) { + if let Some(location) = location.as_ref() { + packed_requests.push(PackedReadRequest { + digest: &segment.digest, + bytes: segment.bytes, + location, + output_offset: segment.offset, + }); } else { - self.read_segment(&segment.digest)? - }; - payload.extend_from_slice(&bytes); + self.append_packed(&packed_requests, &mut payload)?; + packed_requests.clear(); + let bytes = self.read_segment(&segment.digest)?; + payload.extend_from_slice(&bytes); + } } + self.append_packed(&packed_requests, &mut payload)?; if payload.len() != total { bail!( "assembled {} bytes but manifest records {total}", @@ -1194,6 +1197,21 @@ impl HandoffSegmentStore { Ok(payload) } + fn append_packed( + &self, + requests: &[PackedReadRequest<'_>], + payload: &mut Vec, + ) -> Result<()> { + match self.packed.append_many(requests, payload) { + Ok(()) => Ok(()), + Err(failure) => { + let path = self.packed.pack_path(&failure.pack_digest); + let _ = self.quarantine(&path); + Err(failure.error) + } + } + } + pub fn segment_footprint_bytes(&self) -> Result { Ok(directory_bytes(&self.root.join(SEGMENT_DIR))? .saturating_add(self.packed.footprint_bytes()?)) diff --git a/crates/skippy-cache/src/l3/packed.rs b/crates/skippy-cache/src/l3/packed.rs index b91a82b28a..cb6fb92d74 100644 --- a/crates/skippy-cache/src/l3/packed.rs +++ b/crates/skippy-cache/src/l3/packed.rs @@ -52,6 +52,7 @@ pub(super) struct PackedReadRequest<'a> { pub digest: &'a str, pub bytes: u64, pub location: &'a PackedSegmentLocation, + pub output_offset: u64, } #[derive(Debug)] @@ -301,48 +302,130 @@ impl PackedSegmentStore { Ok(()) } - /// Read requests in logical order while opening each physical pack once. - pub(super) fn read_many( + /// Append requests directly into their final payload ranges. + /// + /// Consecutive logical segments that are also consecutive in one pack are + /// issued as one read. Digest verification still happens per logical + /// segment, preserving the manifest contract without allocating one + /// temporary `Vec` for every segment and then copying it into the payload. + pub(super) fn append_many( &self, requests: &[PackedReadRequest<'_>], - ) -> Result>, PackedReadError> { + output: &mut Vec, + ) -> Result<(), PackedReadError> { let mut files = HashMap::::new(); - let mut output = Vec::with_capacity(requests.len()); - for request in requests { - let result = (|| -> Result> { - let file = match files.entry(request.location.pack_digest.clone()) { + let mut first = 0usize; + while first < requests.len() { + let first_request = &requests[first]; + let mut last = first + 1; + let mut physical_end = first_request + .location + .offset + .checked_add(first_request.bytes) + .context("packed read range overflows") + .map_err(|error| PackedReadError { + pack_digest: first_request.location.pack_digest.clone(), + error, + })?; + let mut output_end = first_request + .output_offset + .checked_add(first_request.bytes) + .context("packed output range overflows") + .map_err(|error| PackedReadError { + pack_digest: first_request.location.pack_digest.clone(), + error, + })?; + while let Some(next) = requests.get(last) { + if next.location.pack_digest != first_request.location.pack_digest + || next.location.offset != physical_end + || next.output_offset != output_end + { + break; + } + physical_end = + physical_end + .checked_add(next.bytes) + .ok_or_else(|| PackedReadError { + pack_digest: first_request.location.pack_digest.clone(), + error: anyhow::anyhow!("packed read range overflows"), + })?; + output_end = output_end + .checked_add(next.bytes) + .ok_or_else(|| PackedReadError { + pack_digest: first_request.location.pack_digest.clone(), + error: anyhow::anyhow!("packed output range overflows"), + })?; + last += 1; + } + + let result = (|| -> Result<()> { + if usize::try_from(first_request.output_offset) + .context("packed output offset exceeds usize")? + != output.len() + { + bail!("packed output range is not contiguous with the payload"); + } + let file = match files.entry(first_request.location.pack_digest.clone()) { std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), std::collections::hash_map::Entry::Vacant(entry) => { - let file = File::open(self.pack_path(&request.location.pack_digest)) + let file = File::open(self.pack_path(&first_request.location.pack_digest)) .with_context(|| { - format!("failed to open pack {}", request.location.pack_digest) + format!( + "failed to open pack {}", + first_request.location.pack_digest + ) })?; entry.insert(file) } }; - file.seek(SeekFrom::Start(request.location.offset))?; - let len = usize::try_from(request.bytes).context("segment exceeds usize")?; - let mut bytes = vec![0u8; len]; - file.read_exact(&mut bytes)?; - if segment_digest(&bytes) != request.digest { - bail!( - "packed segment {} failed digest verification", - request.digest - ); + let output_end = + usize::try_from(output_end).context("packed output end exceeds usize")?; + file.seek(SeekFrom::Start(first_request.location.offset))?; + let run_bytes = output_end + .checked_sub(output.len()) + .context("packed output range precedes payload")?; + let read = file + .take(run_bytes as u64) + .read_to_end(output) + .context("failed to read packed payload range")?; + if read != run_bytes { + bail!("packed payload range ended after {read} of {run_bytes} bytes"); } - Ok(bytes) + + for request in &requests[first..last] { + let start = usize::try_from(request.output_offset) + .context("segment output offset exceeds usize")?; + let end = usize::try_from( + request + .output_offset + .checked_add(request.bytes) + .context("segment output range overflows")?, + ) + .context("segment output end exceeds usize")?; + let bytes = output + .get(start..end) + .context("segment output range exceeds payload")?; + if segment_digest(bytes) != request.digest { + bail!( + "packed segment {} failed digest verification", + request.digest + ); + } + } + Ok(()) })(); match result { - Ok(bytes) => output.push(bytes), + Ok(()) => {} Err(error) => { return Err(PackedReadError { - pack_digest: request.location.pack_digest.clone(), + pack_digest: first_request.location.pack_digest.clone(), error, }); } } + first = last; } - Ok(output) + Ok(()) } pub(super) fn rebuild( From cf35b63d4d425ff83ba9a0ffa2c1a27e6a0e1475 Mon Sep 17 00:00:00 2001 From: scama Date: Thu, 10 Sep 2026 12:33:03 +1000 Subject: [PATCH 11/41] perf(skippy-cache): hash payload during packed restore --- crates/skippy-cache/src/l3.rs | 16 +++++++++++----- crates/skippy-cache/src/l3/packed.rs | 4 ++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/skippy-cache/src/l3.rs b/crates/skippy-cache/src/l3.rs index f30cdf35a6..b2301a8e15 100644 --- a/crates/skippy-cache/src/l3.rs +++ b/crates/skippy-cache/src/l3.rs @@ -938,7 +938,7 @@ impl HandoffSegmentStore { }]; let len = usize::try_from(location.bytes).context("segment exceeds usize")?; let mut bytes = Vec::with_capacity(len); - return match self.packed.append_many(&requests, &mut bytes) { + return match self.packed.append_many(&requests, &mut bytes, None) { Ok(()) => Ok(bytes), Err(failure) => { let path = self.packed.pack_path(&failure.pack_digest); @@ -1146,6 +1146,7 @@ impl HandoffSegmentStore { pub fn assemble(&self, manifest: &HandoffManifest) -> Result> { let total = usize::try_from(manifest.total_bytes).context("payload exceeds usize")?; let mut payload = Vec::with_capacity(total); + let mut payload_hasher = blake3::Hasher::new(); let locations = self .packed .load_manifest_index(&manifest.payload_digest, manifest.segments.len())?; @@ -1178,20 +1179,21 @@ impl HandoffSegmentStore { output_offset: segment.offset, }); } else { - self.append_packed(&packed_requests, &mut payload)?; + self.append_packed(&packed_requests, &mut payload, &mut payload_hasher)?; packed_requests.clear(); let bytes = self.read_segment(&segment.digest)?; + payload_hasher.update(&bytes); payload.extend_from_slice(&bytes); } } - self.append_packed(&packed_requests, &mut payload)?; + self.append_packed(&packed_requests, &mut payload, &mut payload_hasher)?; if payload.len() != total { bail!( "assembled {} bytes but manifest records {total}", payload.len() ); } - if segment_digest(&payload) != manifest.payload_digest { + if payload_hasher.finalize().to_hex().as_str() != manifest.payload_digest { bail!("assembled payload failed manifest digest verification"); } Ok(payload) @@ -1201,8 +1203,12 @@ impl HandoffSegmentStore { &self, requests: &[PackedReadRequest<'_>], payload: &mut Vec, + payload_hasher: &mut blake3::Hasher, ) -> Result<()> { - match self.packed.append_many(requests, payload) { + match self + .packed + .append_many(requests, payload, Some(payload_hasher)) + { Ok(()) => Ok(()), Err(failure) => { let path = self.packed.pack_path(&failure.pack_digest); diff --git a/crates/skippy-cache/src/l3/packed.rs b/crates/skippy-cache/src/l3/packed.rs index cb6fb92d74..6a29bee6f7 100644 --- a/crates/skippy-cache/src/l3/packed.rs +++ b/crates/skippy-cache/src/l3/packed.rs @@ -312,6 +312,7 @@ impl PackedSegmentStore { &self, requests: &[PackedReadRequest<'_>], output: &mut Vec, + mut payload_hasher: Option<&mut blake3::Hasher>, ) -> Result<(), PackedReadError> { let mut files = HashMap::::new(); let mut first = 0usize; @@ -412,6 +413,9 @@ impl PackedSegmentStore { ); } } + if let Some(hasher) = payload_hasher.as_deref_mut() { + hasher.update(&output[output_end - run_bytes..output_end]); + } Ok(()) })(); match result { From 114ab6821c5fed8a29904ddcf1b6702dcb3b3978 Mon Sep 17 00:00:00 2001 From: James Dumay Date: Thu, 10 Sep 2026 13:40:27 +1000 Subject: [PATCH 12/41] Report accurate parameter sizes for local GGUF models (#1742) --- .../src/models/profile.rs | 154 +++++++++++++++--- 1 file changed, 134 insertions(+), 20 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/models/profile.rs b/crates/mesh-llm-host-runtime/src/models/profile.rs index 5ea470fab1..6ed3c90783 100644 --- a/crates/mesh-llm-host-runtime/src/models/profile.rs +++ b/crates/mesh-llm-host-runtime/src/models/profile.rs @@ -20,26 +20,18 @@ pub(crate) fn served_model_metadata_for_path( .flatten(); let metadata = match compact { Some(meta) => { - let parameter_size = meta - .parameter_size - .clone() - .or_else(|| parameter_size_from_text(model_name)); - // Authoritative size: sum the GGUF tensor element counts. This is - // the ONLY source — no name-based fallback. If a served model - // cannot be summed from its GGUF, it advertises no size and MoA - // tiering treats it as the lowest-param (weakest) model rather than - // guessing from a brittle name label (per i386 review). - // - // Summed across the whole shard set: `find_model_path` resolves a + // Sum across the whole shard set: `find_model_path` resolves a // split GGUF to its first part, and each shard's tensor-info table // holds only that shard's weights. Scanning one part reported // roughly `total / shard_count` — an ~80B 4-shard model advertised // 24.6B — which silently mis-ranked every size-based decision. - let parameter_count_b = path + let parameter_count = path .exists() .then(|| crate::models::gguf::scan_gguf_bundle_total_parameters(path)) - .flatten() - .map(|total| total as f64 / 1e9); + .flatten(); + let parameter_size = + resolve_parameter_size(model_name, meta.parameter_size.clone(), parameter_count); + let parameter_count_b = parameter_count.map(|total| total as f64 / 1e9); let kv_head_count = meta.effective_kv_head_count(); crate::mesh::ServedModelMetadata { architecture: non_empty(meta.architecture), @@ -61,7 +53,7 @@ pub(crate) fn served_model_metadata_for_path( } } None => crate::mesh::ServedModelMetadata { - parameter_size: parameter_size_from_text(model_name), + parameter_size: resolve_parameter_size(model_name, None, None), // No GGUF to sum -> no authoritative size. Advertise none rather // than a name-guessed count (per i386 review); MoA treats a // sizeless model as the weakest. @@ -90,11 +82,49 @@ fn quant_from_text(value: &str) -> Option { (!quant.is_empty()).then_some(quant) } +/// Resolve a display label consistently: source metadata, verified tensor +/// count, then a guarded model-name fallback. +fn resolve_parameter_size( + model_name: &str, + source_size: Option, + parameter_count: Option, +) -> Option { + source_size + .or_else(|| parameter_count.and_then(parameter_size_from_count)) + .or_else(|| parameter_size_from_text(model_name)) +} + +fn parameter_size_from_count(parameter_count: u64) -> Option { + if parameter_count == 0 { + return None; + } + + if parameter_count < 1_000_000_000 { + let millions = parameter_count as f64 / 1e6; + return Some(if millions >= 10.0 { + format!("{millions:.0}M") + } else { + format!("{millions:.1}M") + }); + } + + let billions = parameter_count as f64 / 1e9; + let label = format!("{billions:.1}"); + Some(format!("{}B", label.strip_suffix(".0").unwrap_or(&label))) +} + fn parameter_size_from_text(text: &str) -> Option { - static MULTIPLIED_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)([bm])").unwrap()); - static SIMPLE_RE: LazyLock = - LazyLock::new(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)([bm])").unwrap()); + if text.starts_with("local-gguf/sha256-") { + return None; + } + + static MULTIPLIED_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(?:^|[^a-z0-9.])(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)([bm])(?:$|[^a-z0-9.])") + .unwrap() + }); + static SIMPLE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(?:^|[^a-z0-9.])(\d+(?:\.\d+)?)([bm])(?:$|[^a-z0-9.])").unwrap() + }); MULTIPLIED_RE .captures(text) @@ -115,7 +145,12 @@ fn parameter_size_from_text(text: &str) -> Option { #[cfg(test)] mod tests { - use super::parameter_size_from_text; + use std::path::Path; + + use super::{ + parameter_size_from_count, parameter_size_from_text, resolve_parameter_size, + served_model_metadata_for_path, + }; #[test] fn extracts_parameter_size_labels() { @@ -128,4 +163,83 @@ mod tests { Some("8x7B") ); } + + #[test] + fn rejects_parameter_size_labels_embedded_in_synthetic_names() { + assert_eq!( + parameter_size_from_text("local-gguf/sha256-74a4da8c9fdbcd15bd1f6e06b796387d397b038d"), + None + ); + assert_eq!(parameter_size_from_text("model-dead8061beef"), None); + assert_eq!(parameter_size_from_text("model-7bfoo"), None); + assert_eq!(parameter_size_from_text("model-8x7bfoo"), None); + } + + #[test] + fn resolves_parameter_size_with_shared_source_precedence() { + assert_eq!( + resolve_parameter_size("model-7B", Some("6B".to_string()), Some(8_000_000_000)) + .as_deref(), + Some("6B") + ); + assert_eq!( + resolve_parameter_size("model-7B", None, Some(8_000_000_000)).as_deref(), + Some("8B") + ); + assert_eq!( + resolve_parameter_size("model-7B", None, None).as_deref(), + Some("7B") + ); + } + + #[test] + fn formats_parameter_size_from_tensor_count() { + assert_eq!(parameter_size_from_count(0), None); + assert_eq!( + parameter_size_from_count(494_000_000).as_deref(), + Some("494M") + ); + assert_eq!( + parameter_size_from_count(1_235_000_000).as_deref(), + Some("1.2B") + ); + assert_eq!( + parameter_size_from_count(32_000_000_000).as_deref(), + Some("32B") + ); + } + + #[test] + fn derives_synthetic_model_parameter_size_from_gguf_tensors() { + let path = std::env::temp_dir().join(format!( + "mesh-llm-profile-parameter-size-{}.gguf", + std::process::id() + )); + write_gguf_with_parameters(&path, 494_000_000); + + let metadata = served_model_metadata_for_path( + "local-gguf/sha256-74a4da8c9fdbcd15bd1f6e06b796387d397b038d", + &path, + ) + .expect("synthetic GGUF should expose metadata"); + + assert_eq!(metadata.parameter_size.as_deref(), Some("494M")); + assert_eq!(metadata.parameter_count_b, Some(0.494)); + let _ = std::fs::remove_file(path); + } + + fn write_gguf_with_parameters(path: &Path, parameters: u64) { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"GGUF"); + bytes.extend_from_slice(&3u32.to_le_bytes()); + bytes.extend_from_slice(&1u64.to_le_bytes()); + bytes.extend_from_slice(&0u64.to_le_bytes()); + bytes.extend_from_slice(&7u64.to_le_bytes()); + bytes.extend_from_slice(b"weights"); + bytes.extend_from_slice(&1u32.to_le_bytes()); + bytes.extend_from_slice(¶meters.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u64.to_le_bytes()); + std::fs::write(path, bytes).expect("write synthetic GGUF"); + } } From 2080997f0ca1e8dc5fdc9a5a6876fdd88e39ee39 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo <728690+ndizazzo@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:40:52 -0400 Subject: [PATCH 13/41] fix(certification): use served package ref for runtime smoke gates (#1741) --- .../src/inference/skippy/certification.rs | 94 ++++++++++++++++--- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs index bcc798e8cf..899fb4a4fe 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs @@ -358,7 +358,7 @@ async fn runtime_smoke_gates( } }; vec![ - smoke_v1_models(&client, api_base, &package.model_id).await, + smoke_v1_models(&client, api_base, &package.package_ref).await, smoke_chat_completions(&client, api_base, package, request).await, smoke_responses(&client, api_base, package, request).await, ] @@ -367,21 +367,25 @@ async fn runtime_smoke_gates( async fn smoke_v1_models( client: &reqwest::Client, api_base: &str, - model_id: &str, + served_model_id: &str, ) -> CertificationGate { let url = format!("{}/v1/models", api_base.trim_end_matches('/')); match client.get(url).send().await { Ok(response) if response.status() == StatusCode::OK => { match response.json::().await { - Ok(value) if models_response_contains(&value, model_id) => CertificationGate { - name: "v1_models".to_string(), - status: CertificationGateStatus::Passed, - details: None, - }, + Ok(value) if models_response_contains(&value, served_model_id) => { + CertificationGate { + name: "v1_models".to_string(), + status: CertificationGateStatus::Passed, + details: None, + } + } Ok(_) => CertificationGate { name: "v1_models".to_string(), status: CertificationGateStatus::Failed, - details: Some(format!("model {model_id:?} was not present in /v1/models")), + details: Some(format!( + "model {served_model_id:?} was not present in /v1/models" + )), }, Err(error) => failed_gate("v1_models", error), } @@ -399,7 +403,7 @@ async fn smoke_chat_completions( ) -> CertificationGate { let url = format!("{}/v1/chat/completions", api_base.trim_end_matches('/')); let body = json!({ - "model": package.model_id, + "model": package.package_ref, "messages": [{ "role": "user", "content": request.prompt }], "max_tokens": request.max_tokens, "stream": false @@ -423,7 +427,7 @@ async fn smoke_responses( ) -> CertificationGate { let url = format!("{}/v1/responses", api_base.trim_end_matches('/')); let body = json!({ - "model": package.model_id, + "model": package.package_ref, "input": request.prompt, "max_output_tokens": request.max_tokens }); @@ -569,8 +573,8 @@ mod tests { use super::{ CertificationGateStatus, aggregate_certification_status, certification_stage_ranges, materialize_package_v2_certification_stages, models_response_contains, - response_has_chat_choice_content, response_has_responses_output, smoke_chat_completions, - smoke_responses, + response_has_chat_choice_content, response_has_responses_output, runtime_smoke_gates, + smoke_chat_completions, smoke_responses, }; use crate::inference::skippy::materialization::{StagePackageInfo, StagePackageLayerInfo}; use serde_json::json; @@ -830,4 +834,70 @@ mod tests { }); format!("http://{addr}") } + + /// Mimics a node that only recognizes `served_model_id` — anything else 404s, + /// the same way a real host does when a client asks for a model name it + /// doesn't advertise. + async fn spawn_certification_stub_server(served_model_id: String) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + for _ in 0..3 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf).await.unwrap(); + let request = String::from_utf8_lossy(&buf[..n]); + let response = if request.starts_with("GET") { + let body = json!({ + "object": "list", + "data": [{ "id": served_model_id }] + }) + .to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + } else if request.contains(&format!("\"model\":\"{served_model_id}\"")) { + let body = if request.contains("/v1/chat/completions") { + json!({"choices": [{"message": {"content": "ok"}}]}) + } else { + json!({"output_text": "ok"}) + } + .to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + } else { + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_string() + }; + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn runtime_smoke_gates_certify_against_served_package_ref() { + // A package's `model_id` is the *source* model recorded in the manifest + // (e.g. an upstream HF ref). The node advertises and routes on + // `package_ref` instead — the ref it was actually started with. The + // fake package below has both, and differs deliberately. + let package = fake_package_info(); + assert_ne!(package.model_id, package.package_ref); + + let api_base = spawn_certification_stub_server(package.package_ref.clone()).await; + let request = super::SkippyCertificationRequest { + api_base: Some(api_base), + ..fake_certification_request() + }; + + let gates = runtime_smoke_gates(&request, &package).await; + + for gate in &gates { + assert_eq!(gate.status, CertificationGateStatus::Passed, "{gate:?}"); + } + } } From efd7ab642b05537e77ae774b6f8fc813215ae1c0 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo <728690+ndizazzo@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:41:07 -0400 Subject: [PATCH 14/41] fix(system): correct day/month transposition in macOS ps lstart parsing (#1744) --- crates/mesh-llm-system/src/process.rs | 168 +++++++++++++++++--------- 1 file changed, 108 insertions(+), 60 deletions(-) diff --git a/crates/mesh-llm-system/src/process.rs b/crates/mesh-llm-system/src/process.rs index 0a46bab458..e6e1a966d8 100644 --- a/crates/mesh-llm-system/src/process.rs +++ b/crates/mesh-llm-system/src/process.rs @@ -132,75 +132,74 @@ mod platform { if s.is_empty() { return Ok(None); } - parse_lstart(&s) + super::parse_lstart(&s) } - fn parse_lstart(s: &str) -> anyhow::Result> { - use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone}; - - let parts: Vec<&str> = s.split_whitespace().collect(); - if parts.len() != 5 { - return Ok(None); - } - - let day: u32 = match parts[1].parse() { - Ok(d) => d, - Err(_) => return Ok(None), - }; - let month: u32 = match parts[2] { - "Jan" => 1, - "Feb" => 2, - "Mar" => 3, - "Apr" => 4, - "May" => 5, - "Jun" => 6, - "Jul" => 7, - "Aug" => 8, - "Sep" => 9, - "Oct" => 10, - "Nov" => 11, - "Dec" => 12, - _ => return Ok(None), - }; - let year: i32 = match parts[4].parse() { - Ok(y) => y, - Err(_) => return Ok(None), - }; + pub fn process_executable_name(pid: u32) -> anyhow::Result> { + process_comm(pid) + } +} - let time_parts: Vec<&str> = parts[3].split(':').collect(); - if time_parts.len() != 3 { - return Ok(None); - } - let (hour, min, sec): (u32, u32, u32) = match ( - time_parts[0].parse(), - time_parts[1].parse(), - time_parts[2].parse(), - ) { - (Ok(h), Ok(m), Ok(s)) => (h, m, s), - _ => return Ok(None), - }; +/// Parse the output of `ps -o lstart=` under `LANG=C`/`LC_ALL=C`, e.g. +/// `Wed Sep 9 18:43:39 2026`: weekday, month, day, time, year, and convert +/// it to a Unix timestamp in the local timezone. +#[cfg(target_os = "macos")] +fn parse_lstart(s: &str) -> anyhow::Result> { + use chrono::{Local, TimeZone}; - let date = match NaiveDate::from_ymd_opt(year, month, day) { - Some(d) => d, - None => return Ok(None), - }; - let time = match NaiveTime::from_hms_opt(hour, min, sec) { - Some(t) => t, - None => return Ok(None), - }; - let naive_dt = NaiveDateTime::new(date, time); + let Some(naive_dt) = parse_lstart_naive(s) else { + return Ok(None); + }; - let local_dt = match Local.from_local_datetime(&naive_dt).single() { - Some(dt) => dt, - None => return Ok(None), - }; + match Local.from_local_datetime(&naive_dt).single() { + Some(dt) => Ok(Some(dt.timestamp())), + None => Ok(None), + } +} - Ok(Some(local_dt.timestamp())) +/// Parses the weekday/month/day/time/year fields into a naive (timezone-free) +/// datetime. Split out of [`parse_lstart`] and out of the macOS-only platform +/// module (its only real caller) so this field decoding is unit-tested on +/// every CI runner rather than only a macOS one, and so the test doesn't +/// depend on the runner's local timezone. +#[cfg(any(test, target_os = "macos"))] +fn parse_lstart_naive(s: &str) -> Option { + use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + + let parts: Vec<&str> = s.split_whitespace().collect(); + if parts.len() != 5 { + return None; } - pub fn process_executable_name(pid: u32) -> anyhow::Result> { - process_comm(pid) + let month: u32 = match parts[1] { + "Jan" => 1, + "Feb" => 2, + "Mar" => 3, + "Apr" => 4, + "May" => 5, + "Jun" => 6, + "Jul" => 7, + "Aug" => 8, + "Sep" => 9, + "Oct" => 10, + "Nov" => 11, + "Dec" => 12, + _ => return None, + }; + let day: u32 = parts[2].parse().ok()?; + let year: i32 = parts[4].parse().ok()?; + + let time_parts: Vec<&str> = parts[3].split(':').collect(); + if time_parts.len() != 3 { + return None; } + let hour: u32 = time_parts[0].parse().ok()?; + let min: u32 = time_parts[1].parse().ok()?; + let sec: u32 = time_parts[2].parse().ok()?; + + let date = NaiveDate::from_ymd_opt(year, month, day)?; + let time = NaiveTime::from_hms_opt(hour, min, sec)?; + Some(NaiveDateTime::new(date, time)) } #[cfg(not(any(target_os = "linux", target_os = "macos")))] @@ -271,3 +270,52 @@ pub fn current_process_start_time_unix() -> anyhow::Result { process_started_at_unix(std::process::id())? .ok_or_else(|| anyhow::anyhow!("could not determine start time of current process")) } + +#[cfg(test)] +mod tests { + use super::parse_lstart_naive; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + + fn naive(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> NaiveDateTime { + NaiveDateTime::new( + NaiveDate::from_ymd_opt(year, month, day).unwrap(), + NaiveTime::from_hms_opt(hour, min, sec).unwrap(), + ) + } + + #[test] + fn parses_single_digit_day_padded_with_a_double_space() { + // macOS `ps -o lstart=` right-aligns the day to two columns, so a + // single-digit day leaves two spaces before it — exactly what F8 + // observed (galaxy's process started on Sep 9). + let parsed = parse_lstart_naive("Wed Sep 9 18:43:39 2026").unwrap(); + + assert_eq!(parsed, naive(2026, 9, 9, 18, 43, 39)); + } + + #[test] + fn parses_double_digit_day() { + let parsed = parse_lstart_naive("Mon Jan 12 09:05:00 2026").unwrap(); + + assert_eq!(parsed, naive(2026, 1, 12, 9, 5, 0)); + } + + #[test] + fn does_not_transpose_a_month_number_larger_than_any_day() { + // Regression guard for the day/month swap: December (month 12) used + // to get read as if it were the day field. + let parsed = parse_lstart_naive("Thu Dec 3 00:00:00 2026").unwrap(); + + assert_eq!(parsed, naive(2026, 12, 3, 0, 0, 0)); + } + + #[test] + fn rejects_malformed_field_count() { + assert!(parse_lstart_naive("Sep 9 18:43:39 2026").is_none()); + } + + #[test] + fn rejects_unknown_month_name() { + assert!(parse_lstart_naive("Wed Foo 9 18:43:39 2026").is_none()); + } +} From 7247a397aa4fce0bdfd7d09bb46dabe7222b4249 Mon Sep 17 00:00:00 2001 From: scama Date: Thu, 10 Sep 2026 14:08:59 +1000 Subject: [PATCH 15/41] perf(skippy-server): prewarm generation graph before readiness --- .../src/frontend/generation/queue.rs | 11 ++++++++--- .../src/runtime_state/lane_lifecycle.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/skippy-server/src/frontend/generation/queue.rs b/crates/skippy-server/src/frontend/generation/queue.rs index 3ead8ccaca..af96b5905c 100644 --- a/crates/skippy-server/src/frontend/generation/queue.rs +++ b/crates/skippy-server/src/frontend/generation/queue.rs @@ -804,10 +804,11 @@ pub(in crate::frontend) fn prewarm_generation_sessions( event_name: &'static str, ) -> Result<()> { let timer = PhaseTimer::start(); - let sessions = runtime + let mut runtime = runtime .lock() - .map_err(|_| anyhow!("runtime lock poisoned"))? - .prewarm_idle_sessions(generation_concurrency)?; + .map_err(|_| anyhow!("runtime lock poisoned"))?; + let generation_graph_warmed = runtime.warmup_generation_graph()?; + let sessions = runtime.prewarm_idle_sessions(generation_concurrency)?; let mut attrs = lifecycle_attrs(config); attrs.insert( "llama_stage.generation_concurrency".to_string(), @@ -825,6 +826,10 @@ pub(in crate::frontend) fn prewarm_generation_sessions( "llama_stage.runtime_sessions_idle".to_string(), json!(sessions.idle_sessions), ); + attrs.insert( + "llama_stage.generation_graph_warmed".to_string(), + json!(generation_graph_warmed), + ); attrs.insert( "llama_stage.elapsed_ms".to_string(), json!(timer.elapsed_ms()), diff --git a/crates/skippy-server/src/runtime_state/lane_lifecycle.rs b/crates/skippy-server/src/runtime_state/lane_lifecycle.rs index 5419d67bc1..d29997b07c 100644 --- a/crates/skippy-server/src/runtime_state/lane_lifecycle.rs +++ b/crates/skippy-server/src/runtime_state/lane_lifecycle.rs @@ -127,6 +127,24 @@ impl RuntimeState { Ok(self.session_stats()) } + pub(crate) fn warmup_generation_graph(&self) -> Result { + if self.model.input_activation_boundary().is_some() + || self.model.output_activation_boundary().is_some() + { + return Ok(false); + } + let token_id = self + .model + .tokenize("", true)? + .into_iter() + .next() + .unwrap_or(0); + let mut session = self.model.create_session()?; + session.decode_step(token_id)?; + session.reset()?; + Ok(true) + } + /// Release the session slot identified by `session_id`. /// /// This is the cleanup path called at the end of every chat From 000cfac295a6bca0d41ada492e28d3042f79f36b Mon Sep 17 00:00:00 2001 From: jy Date: Thu, 10 Sep 2026 14:23:31 +1000 Subject: [PATCH 16/41] feat(skippy-cache): add bounded host-RAM L2 tier prototype (#1651) First slice of #1651: a standalone, opt-in L2 tier over the packed L3 segment format, with no restore-path edits so it cannot collide with #1649. - crates/skippy-cache/src/l2: L2Tier stores assembled exact-state payloads under the same (namespace, token-path) coordinates and model/state identities L3 uses. Entries keep the whole-payload BLAKE3 digest and reads verify it; a mismatch drops the entry and records a miss, never serves state. The byte budget is enforced on every insert with deterministic LRU eviction (ties break on cache key); oversized and empty payloads are refused, mirroring L3 spill rules. Reads clone Arc-backed CacheBytes handles rather than bytes. - crates/skippy-bench l2-tier: cold L3 fill versus warm L2 fill on the same packed entry, with a byte-equality correctness gate before any timing is reported. On this machine, 3,994-token x 4 KiB/token entries: L3 fill p50 22.3 ms versus L2 fill p50 1.75 us. Validation: cargo test -p skippy-cache --lib (132 passed), clippy -D warnings, fmt --check. --- Cargo.lock | 1 + crates/skippy-bench/Cargo.toml | 1 + crates/skippy-bench/src/cli.rs | 29 ++ crates/skippy-bench/src/l2_tier.rs | 159 +++++++ crates/skippy-bench/src/main.rs | 2 + crates/skippy-cache/src/l2/mod.rs | 695 +++++++++++++++++++++++++++++ crates/skippy-cache/src/lib.rs | 5 + 7 files changed, 892 insertions(+) create mode 100644 crates/skippy-bench/src/l2_tier.rs create mode 100644 crates/skippy-cache/src/l2/mod.rs diff --git a/Cargo.lock b/Cargo.lock index bd07b7f06c..dc57c88276 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7389,6 +7389,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "skippy-cache", "skippy-protocol", "skippy-runtime", "skippy-topology", diff --git a/crates/skippy-bench/Cargo.toml b/crates/skippy-bench/Cargo.toml index 8604d172aa..d845ecf5f5 100644 --- a/crates/skippy-bench/Cargo.toml +++ b/crates/skippy-bench/Cargo.toml @@ -10,6 +10,7 @@ clap.workspace = true csv = "1.4.0" dirs = "6.0.0" libc = "0.2" +skippy-cache = { path = "../skippy-cache" } skippy-protocol = { path = "../skippy-protocol" } skippy-runtime = { path = "../skippy-runtime" } skippy-topology = { path = "../skippy-topology" } diff --git a/crates/skippy-bench/src/cli.rs b/crates/skippy-bench/src/cli.rs index 8b68c33065..4a81dc30e5 100644 --- a/crates/skippy-bench/src/cli.rs +++ b/crates/skippy-bench/src/cli.rs @@ -35,6 +35,8 @@ pub enum CommandKind { LocalSplitChainBinary(LocalSplitChainBinaryArgs), #[command(name = "verify-window-local")] VerifyWindowLocal(VerifyWindowLocalArgs), + #[command(name = "l2-tier")] + L2Tier(L2TierArgs), #[command(name = "chat-corpus")] ChatCorpus(ChatCorpusArgs), #[command(name = "token-lengths")] @@ -45,6 +47,33 @@ pub enum CommandKind { Run(RunArgs), } +#[derive(Parser)] +pub struct L2TierArgs { + /// Working directory for the temporary L3 store. Created and removed by + /// the run unless `--keep-store` is set. + #[arg(long, default_value = "/tmp/skippy-l2-tier-bench")] + pub store_root: PathBuf, + /// Number of timed L3-cold / L2-warm matched pairs after warmup. + #[arg(long, default_value_t = 50)] + pub pairs: usize, + /// Recorded prefix length in tokens (the synthetic conversation length). + #[arg(long, default_value_t = 1_893)] + pub tokens: usize, + /// Bytes of KV payload per token — sized to mimic a real dense model's + /// per-token KV footprint at the target dtype. + #[arg(long, default_value_t = 512)] + pub kv_bytes_per_token: usize, + /// L2 budget in MiB. Defaults to four times one entry. + #[arg(long)] + pub l2_budget_mib: Option, + /// Keep the L3 store directory after the run for inspection. + #[arg(long, default_value_t = false)] + pub keep_store: bool, + /// Model identity stamped into the L3 tier and L2 keys. + #[arg(long, default_value = "bench-model")] + pub model_identity: String, +} + #[derive(Parser)] pub struct EvalArgs { #[command(subcommand)] diff --git a/crates/skippy-bench/src/l2_tier.rs b/crates/skippy-bench/src/l2_tier.rs new file mode 100644 index 0000000000..65b796b2f5 --- /dev/null +++ b/crates/skippy-bench/src/l2_tier.rs @@ -0,0 +1,159 @@ +//! `l2-tier` benchmark: cold L3 fill versus warm L2 fill on identical packed +//! entries (#1651). +//! +//! Builds a temporary L3 store, spills a synthetic multi-turn prompt at a +//! recorded prefix length, then measures two restore paths in-process: +//! +//! - **L3 cold fill**: `L3Tier::fill_longest` — index probe + segment +//! assembly + digest verification from disk. +//! - **L2 warm fill**: `L2Tier::peek` + `get` — the entry was captured from +//! an identical L3 fill, so the hit is a handle clone plus digest check. +//! +//! Both paths produce payloads with identical bytes; the harness asserts +//! that before timing so a correctness regression cannot hide behind a +//! speedup. Output goes to stdout as JSON lines. +use std::time::Instant; + +use anyhow::{Context, Result}; + +use crate::cli::L2TierArgs; +use skippy_cache::{ + ExactStatePayload, ExactStatePayloadMirror, L2Origin, L2Tier, l2_cache_key, l3_prefix_key, +}; + +fn percentile(samples_ns: &mut [u128], pct: f64) -> f64 { + samples_ns.sort_unstable(); + let index = ((pct / 100.0) * (samples_ns.len() as f64 - 1.0)).round() as usize; + samples_ns[index.min(samples_ns.len() - 1)] as f64 +} + +pub fn l2_tier(args: L2TierArgs) -> Result<()> { + let namespace = "bench-namespace"; + let state_identity = args.model_identity.clone(); + let token_ids: Vec = (0..args.tokens).map(|i| (i % 128_000) as i32).collect(); + + // Deterministic synthetic KV payload: content matters only for digests, + // size matters for timing. + let payload_len = args.tokens * args.kv_bytes_per_token; + let payload_bytes: Vec = (0..payload_len).map(|i| (i % 251) as u8).collect(); + let payload = ExactStatePayload::full_state(payload_bytes); + + let _ = std::fs::remove_dir_all(&args.store_root); + let tier = skippy_cache::L3Tier::open( + args.store_root.clone(), + (payload_len as u64) * 8, + state_identity.clone(), + 64 * 1024, + ) + .context("failed to open bench L3 tier")?; + + // Spill once: this is the population path, not the measured path. + let manifest_key = tier + .spill(namespace, &token_ids, &payload, None, None) + .context("bench spill failed")?; + let _ = manifest_key; + + // Locate once to learn the recorded prefix key/digest used by both paths. + let location = tier + .locate_longest(namespace, &token_ids, 8) + .context("bench locate failed")? + .context("bench spill was not locatable")?; + let manifest = tier.store().load_manifest(&location.manifest_key)?; + let payload_digest = manifest.payload_digest.clone(); + let recorded_tokens = manifest.token_count; + + let l2_budget_bytes = args + .l2_budget_mib + .map(|mib| mib * 1024 * 1024) + .unwrap_or(payload_len as u64 * 4); + let l2 = L2Tier::new(l2_budget_bytes); + + // Warmup: one of each path, then capture the L3 fill into L2 so the + // warm path is genuinely populated from L3, not inserted by fiat. + let warm_fill = tier + .fill_longest(namespace, &token_ids, 8) + .context("bench warmup L3 fill failed")? + .context("bench warmup L3 fill missed")?; + let cache_key = l2_cache_key(&args.model_identity, &state_identity, namespace, &token_ids); + l2.insert( + cache_key.clone(), + warm_fill.token_count, + payload_digest.clone(), + ExactStatePayloadMirror::capture(&warm_fill.payload), + L2Origin::FromL3, + ) + .map_err(|refusal| anyhow::anyhow!("bench warmup L2 insert refused: {}", refusal.reason()))?; + + let mut l3_samples: Vec = Vec::with_capacity(args.pairs); + let mut l2_samples: Vec = Vec::with_capacity(args.pairs); + + for pair in 0..args.pairs { + // Cold-ish L3 fill: the OS page cache will help after warmup, which + // matches the production comparison — both paths run on the same + // machine state, the delta is the tier delta. + let start = Instant::now(); + let fill = tier + .fill_longest(namespace, &token_ids, 8) + .context("bench L3 fill failed")? + .context("bench L3 fill missed")?; + let l3_ns = start.elapsed().as_nanos(); + + let start = Instant::now(); + let hit = l2 + .peek(&cache_key) + .filter(|peek| peek.payload_digest == payload_digest) + .map(|_| ()) + .and_then(|()| l2.get(&cache_key, &payload_digest)); + let l2_ns = start.elapsed().as_nanos(); + + let hit = hit.context("bench L2 get missed after peek")?; + // Correctness gate: L2 must return byte-identical state to the L3 + // fill, or the speedup is meaningless. + let (l3_bytes, _) = fill.payload.full_state_bytes_timed().context("l3 bytes")?; + let l2_payload = hit.payload.to_payload(); + let (l2_bytes, _) = l2_payload.full_state_bytes_timed().context("l2 bytes")?; + anyhow::ensure!( + l3_bytes == l2_bytes, + "pair {pair}: L2 payload diverged from L3 fill" + ); + anyhow::ensure!(hit.token_count == fill.token_count); + let _ = (l3_bytes, l2_bytes); + + l3_samples.push(l3_ns); + l2_samples.push(l2_ns); + } + + let stats = l2.stats(); + let mut l3_sorted = l3_samples.clone(); + let mut l2_sorted = l2_samples.clone(); + let summary = serde_json::json!({ + "bench": "l2-tier", + "pairs": args.pairs, + "tokens": recorded_tokens, + "payload_bytes": payload_len, + "l2_budget_bytes": l2_budget_bytes, + "model_identity": args.model_identity, + "l3_fill_ns": { + "p50": percentile(&mut l3_sorted, 50.0), + "p99": percentile(&mut l3_sorted, 99.0), + }, + "l2_fill_ns": { + "p50": percentile(&mut l2_sorted, 50.0), + "p99": percentile(&mut l2_sorted, 99.0), + }, + "speedup_p50": percentile(&mut l3_sorted, 50.0) / percentile(&mut l2_sorted, 50.0).max(1.0), + "l2_stats": { + "hits": stats.hits, + "misses": stats.misses, + "evictions": stats.evictions, + "bytes": stats.bytes, + }, + "l3_prefix_key": l3_prefix_key(namespace, &token_ids), + }); + println!("{summary}"); + + if !args.keep_store { + let _ = std::fs::remove_dir_all(&args.store_root); + } + Ok(()) +} diff --git a/crates/skippy-bench/src/main.rs b/crates/skippy-bench/src/main.rs index 1f0de777b0..39743c8bd7 100644 --- a/crates/skippy-bench/src/main.rs +++ b/crates/skippy-bench/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod direct_return_listener; mod distributed; mod evals; +mod l2_tier; mod local_single; mod local_split; mod model_identity; @@ -54,6 +55,7 @@ fn main() -> Result<()> { CommandKind::LocalSplitCompare(args) => local_split_compare(args), CommandKind::LocalSplitChainBinary(args) => local_split_chain_binary(args), CommandKind::VerifyWindowLocal(args) => verify_window_local(args), + CommandKind::L2Tier(args) => l2_tier::l2_tier(args), CommandKind::ChatCorpus(args) => chat_corpus(args), CommandKind::TokenLengths(args) => token_lengths(args), CommandKind::FocusedRuntime(args) => focused_runtime(args), diff --git a/crates/skippy-cache/src/l2/mod.rs b/crates/skippy-cache/src/l2/mod.rs new file mode 100644 index 0000000000..26bb21d060 --- /dev/null +++ b/crates/skippy-cache/src/l2/mod.rs @@ -0,0 +1,695 @@ +//! Host-RAM L2 tier over the packed L3 segment format (#1651). +//! +//! The radix cache (L1) holds resident payloads; the L3 tier holds the same +//! state durably on disk as content-addressed packed segments. This module +//! adds the missing middle tier: a bounded host-RAM cache of *assembled L3 +//! entries*, keyed and identified exactly like the L3 entries they mirror. +//! +//! Contract (mirrors `crate::tier::L3Tier`): +//! +//! - **Identity**: entries are stamped with the tier's model and exact-state +//! identities. The cache key includes the identities, so a re-identity is +//! a wholesale miss, never a silent hit. +//! - **Coordinates**: entries are keyed by the same +//! `(namespace, token path)` coordinates L3 uses — +//! [`crate::tier::l3_prefix_key`] / [`crate::tier::l3_namespace_key`] — so +//! an L2 hit is interchangeable with the L3 entry it cached. +//! - **Integrity**: stored payloads keep their whole-payload BLAKE3 digest +//! (the manifest key). Reads verify the digest; a mismatch is recorded, +//! dropped, and reported as absence — never returned as state. +//! - **Bounded**: the byte budget is enforced on every insert by evicting in +//! deterministic LRU order. A payload larger than the budget is refused. +//! - **Zero-copy reads**: `get` clones the handle, not the bytes; callers +//! receive `CacheBytes` mirrors of the stored buffers. +//! +//! This first slice is a standalone store with no wiring into the request +//! path; the benchmark harness drives it directly. L2 promotion/demotion +//! policy and server integration land in a later slice. +use std::collections::HashMap; +use std::sync::{ + Mutex, + atomic::{AtomicU64, Ordering}, +}; + +use crate::payload::{CacheBytes, ExactStatePayloadKind}; + +/// Where an entry came from, for telemetry and promotion policy later. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum L2Origin { + /// Copied out of an assembled L3 fill. + FromL3, + /// Inserted directly (tests, prefetch, or a future wire source). + Direct, +} + +/// LRU eviction accounting for one removed entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct L2Eviction { + pub cache_key: String, + pub payload_bytes: u64, +} + +/// Read path counters. One snapshot per `stats()` call. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct L2Stats { + pub entries: u64, + pub bytes: u64, + pub budget_bytes: u64, + pub hits: u64, + pub misses: u64, + pub inserts: u64, + pub evictions: u64, + pub digest_mismatches: u64, + pub refused_bytes: u64, +} + +/// What L2 actually stores: the payload bytes split the way +/// `ExactStatePayload` splits them, so a fill can be rebuilt cheaply. +#[derive(Debug, Clone)] +pub enum ExactStatePayloadMirror { + FullState { + bytes: CacheBytes, + }, + RecurrentOnly { + recurrent: CacheBytes, + }, + KvRecurrent { + kv: CacheBytes, + recurrent: CacheBytes, + }, +} + +impl ExactStatePayloadMirror { + pub fn from_parts(kind: ExactStatePayloadKind, kv: CacheBytes, recurrent: CacheBytes) -> Self { + match kind { + ExactStatePayloadKind::FullState => Self::FullState { bytes: kv }, + ExactStatePayloadKind::RecurrentOnly => Self::RecurrentOnly { recurrent }, + ExactStatePayloadKind::KvRecurrent => Self::KvRecurrent { kv, recurrent }, + } + } + + pub fn byte_len(&self) -> u64 { + match self { + Self::FullState { bytes } => bytes.len(), + Self::RecurrentOnly { recurrent } => recurrent.len(), + Self::KvRecurrent { kv, recurrent } => kv.len().saturating_add(recurrent.len()), + } + } + + /// Capture a serving payload into a mirror. Any internal block + /// reconstruction is shared via `CacheBytes` handles, not copied. + pub fn capture(payload: &crate::payload::ExactStatePayload) -> Self { + match payload { + crate::payload::ExactStatePayload::FullState { bytes } => Self::FullState { + bytes: bytes.clone(), + }, + crate::payload::ExactStatePayload::RecurrentOnly { recurrent } => Self::RecurrentOnly { + recurrent: recurrent.clone(), + }, + crate::payload::ExactStatePayload::KvRecurrent { kv, recurrent } => Self::KvRecurrent { + kv: kv.clone(), + recurrent: recurrent.clone(), + }, + } + } + + /// Rebuild a serving payload from the mirror. Cheap: `CacheBytes` is + /// `Arc`-backed, so this shares the stored buffers rather than copying. + pub fn to_payload(&self) -> crate::payload::ExactStatePayload { + match self { + Self::FullState { bytes } => crate::payload::ExactStatePayload::FullState { + bytes: bytes.clone(), + }, + Self::RecurrentOnly { recurrent } => crate::payload::ExactStatePayload::RecurrentOnly { + recurrent: recurrent.clone(), + }, + Self::KvRecurrent { kv, recurrent } => crate::payload::ExactStatePayload::KvRecurrent { + kv: kv.clone(), + recurrent: recurrent.clone(), + }, + } + } +} + +/// Verified hit. +#[derive(Debug, Clone)] +pub struct L2Hit { + pub payload: ExactStatePayloadMirror, + pub token_count: u64, + pub payload_digest: String, +} + +/// Presence probe result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct L2Peek { + pub token_count: u64, + pub payload_digest: String, + pub payload_bytes: u64, + pub origin: L2Origin, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum L2InsertRefusal { + EmptyPayload, + OverBudget { payload_bytes: u64 }, + MalformedDigest, +} + +impl L2InsertRefusal { + pub fn reason(&self) -> &'static str { + match self { + Self::EmptyPayload => "refusing to cache an empty exact-state payload", + Self::OverBudget { .. } => { + "payload exceeds the entire L2 budget; caching it would evict everything else" + } + Self::MalformedDigest => "payload digest is not a 64-hex-character blake3 string", + } + } +} + +#[derive(Debug)] +struct L2Entry { + payload: ExactStatePayloadMirror, + token_count: u64, + payload_digest: String, + origin: L2Origin, + /// LRU clock, bumped on every hit/probe. + last_used: u64, + payload_bytes: u64, +} + +#[derive(Default)] +struct L2Inner { + map: HashMap, + bytes: u64, + clock: u64, +} + +/// Counters kept outside the map lock so `stats()` never blocks hits. +#[derive(Default)] +struct L2AtomicStats { + hits: AtomicU64, + misses: AtomicU64, + inserts: AtomicU64, + evictions: AtomicU64, + digest_mismatches: AtomicU64, + refused_bytes: AtomicU64, +} + +/// Bounded host-RAM L2 of assembled exact-state payloads. +pub struct L2Tier { + inner: Mutex, + budget_bytes: u64, + stats: L2AtomicStats, +} + +impl L2Tier { + pub fn new(budget_bytes: u64) -> Self { + Self { + inner: Mutex::new(L2Inner::default()), + budget_bytes, + stats: L2AtomicStats::default(), + } + } + + pub fn budget_bytes(&self) -> u64 { + self.budget_bytes + } + + /// Insert an assembled entry. `payload_digest` is the whole-payload + /// BLAKE3 (the L3 manifest key) and is verified on read. Returns the + /// evictions the insert caused, so callers and tests can assert policy. + pub fn insert( + &self, + cache_key: String, + token_count: u64, + payload_digest: String, + payload: ExactStatePayloadMirror, + origin: L2Origin, + ) -> Result, L2InsertRefusal> { + let payload_bytes = payload.byte_len(); + if payload_bytes == 0 { + // Mirrors L3: an empty payload cannot represent state. + return Err(L2InsertRefusal::EmptyPayload); + } + if payload_bytes > self.budget_bytes { + self.stats + .refused_bytes + .fetch_add(payload_bytes, Ordering::Relaxed); + return Err(L2InsertRefusal::OverBudget { payload_bytes }); + } + let digest_is_hex = + payload_digest.len() == 64 && payload_digest.bytes().all(|b| b.is_ascii_hexdigit()); + if !digest_is_hex { + return Err(L2InsertRefusal::MalformedDigest); + } + let mut inner = self.inner.lock().expect("L2 map lock poisoned"); + // One entry per cache key: a re-insert at the same coordinates is a + // replacement (fresher state for the same prefix), not a duplicate. + if let Some(existing) = inner.map.remove(&cache_key) { + inner.bytes = inner.bytes.saturating_sub(existing.payload_bytes); + } + inner.clock = inner.clock.wrapping_add(1); + let last_used = inner.clock; + inner.map.insert( + cache_key.clone(), + L2Entry { + payload, + token_count, + payload_digest, + origin, + last_used, + payload_bytes, + }, + ); + inner.bytes = inner.bytes.saturating_add(payload_bytes); + self.stats.inserts.fetch_add(1, Ordering::Relaxed); + let evictions = self.evict_to_budget(&mut inner, &cache_key); + Ok(evictions) + } + + /// A hit records recency and returns a clone of the stored mirror + /// (handle clones, not byte copies). A digest mismatch drops the entry + /// and counts as a miss: L2 must never serve state it cannot verify. + pub fn get(&self, cache_key: &str, expected_digest: &str) -> Option { + let mut inner = self.inner.lock().expect("L2 map lock poisoned"); + if inner + .map + .get(cache_key) + .is_none_or(|entry| entry.payload_digest != expected_digest) + { + // Digest mismatch drops the unverifiable entry; plain absence + // falls through as a recorded miss. + if let Some(removed) = inner.map.remove(cache_key) { + debug_assert!(removed.payload_digest != expected_digest); + inner.bytes = inner.bytes.saturating_sub(removed.payload_bytes); + self.stats.digest_mismatches.fetch_add(1, Ordering::Relaxed); + } + self.stats.misses.fetch_add(1, Ordering::Relaxed); + return None; + } + inner.clock = inner.clock.wrapping_add(1); + let now = inner.clock; + let entry = inner + .map + .get_mut(cache_key) + .expect("presence checked above"); + entry.last_used = now; + self.stats.hits.fetch_add(1, Ordering::Relaxed); + Some(L2Hit { + payload: entry.payload.clone(), + token_count: entry.token_count, + payload_digest: entry.payload_digest.clone(), + }) + } + + /// Presence probe without byte or digest work — the L2 equivalent of an + /// L3 index probe. The caller still `get`s with the expected digest + /// before serving state. + pub fn peek(&self, cache_key: &str) -> Option { + let mut inner = self.inner.lock().expect("L2 map lock poisoned"); + inner.clock += 1; + let now = inner.clock; + let entry = inner.map.get_mut(cache_key)?; + entry.last_used = now; + let peek = L2Peek { + token_count: entry.token_count, + payload_digest: entry.payload_digest.clone(), + payload_bytes: entry.payload_bytes, + origin: entry.origin, + }; + Some(peek) + } + + pub fn remove(&self, cache_key: &str) -> Option { + let mut inner = self.inner.lock().expect("L2 map poisoned"); + let removed = inner.map.remove(cache_key)?; + inner.bytes = inner.bytes.saturating_sub(removed.payload_bytes); + Some(L2Eviction { + cache_key: cache_key.to_string(), + payload_bytes: removed.payload_bytes, + }) + } + + /// Drop everything; returns the bytes released. + pub fn clear(&self) -> u64 { + let mut inner = self.inner.lock().expect("L2 map lock poisoned"); + let bytes = inner.bytes; + inner.map.clear(); + inner.bytes = 0; + bytes + } + + pub fn len(&self) -> usize { + self.inner.lock().expect("L2 map lock poisoned").map.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Point-in-time snapshot combining atomics with the locked totals. + pub fn stats(&self) -> L2Stats { + let inner = self.inner.lock().expect("L2 map lock poisoned"); + L2Stats { + entries: inner.map.len() as u64, + bytes: inner.bytes, + budget_bytes: self.budget_bytes, + hits: self.stats.hits.load(Ordering::Relaxed), + misses: self.stats.misses.load(Ordering::Relaxed), + inserts: self.stats.inserts.load(Ordering::Relaxed), + evictions: self.stats.evictions.load(Ordering::Relaxed), + digest_mismatches: self.stats.digest_mismatches.load(Ordering::Relaxed), + refused_bytes: self.stats.refused_bytes.load(Ordering::Relaxed), + } + } + + fn evict_to_budget(&self, inner: &mut L2Inner, protect_key: &str) -> Vec { + let mut evictions = Vec::new(); + while inner.bytes > self.budget_bytes { + // Deterministic LRU: lowest last_used wins; ties break on cache + // key so identical operation sequences produce identical + // evictions. + let victim = inner + .map + .iter() + .filter(|(key, _)| key.as_str() != protect_key) + .min_by(|a, b| a.1.last_used.cmp(&b.1.last_used).then_with(|| a.0.cmp(b.0))) + .map(|(key, _)| key.clone()); + let Some(victim) = victim else { break }; + if let Some(removed) = inner.map.remove(&victim) { + inner.bytes = inner.bytes.saturating_sub(removed.payload_bytes); + self.stats.evictions.fetch_add(1, Ordering::Relaxed); + evictions.push(L2Eviction { + cache_key: victim, + payload_bytes: removed.payload_bytes, + }); + } else { + break; + } + } + evictions + } +} + +/// Build the L2 cache key from the same coordinates L3 uses, plus the +/// identities the tier serves. Same coordinates under different identities +/// get different keys: an identity change cannot cross-contaminate. +pub fn l2_cache_key( + model_identity: &str, + state_identity: &str, + namespace: &str, + token_ids: &[i32], +) -> String { + let namespace_key = crate::tier::l3_namespace_key(namespace); + let prefix_key = crate::tier::l3_prefix_key(namespace, token_ids); + let mut hasher = blake3::Hasher::new(); + hasher.update(b"l2-cache-key-v1"); + hasher.update(model_identity.as_bytes()); + hasher.update(b"\0"); + hasher.update(state_identity.as_bytes()); + hasher.update(b"\0"); + hasher.update(namespace_key.as_bytes()); + hasher.update(prefix_key.as_bytes()); + format!("blake3:{}", hasher.finalize().to_hex()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const DIGEST_A: &str = "a616719e0a0d39dc0fe85cd2d0a5e0e2f5e6e10b6b5a0a6f1a1c1d3e5f708a90"; + const DIGEST_B: &str = "b616719e0a0d39dc0fe85cd2d0a5e0e2f5e6e10b6b5a0a6f1a1c1d3e5f708a90"; + + fn full_state_mirror(len: usize) -> ExactStatePayloadMirror { + ExactStatePayloadMirror::FullState { + bytes: CacheBytes::inline(vec![7u8; len]), + } + } + + fn key(namespace: &str, tokens: &[i32]) -> String { + l2_cache_key("model-a", "state-a", namespace, tokens) + } + + #[test] + fn insert_get_round_trip_verifies_digest() { + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[1, 2, 3]); + tier.insert( + k.clone(), + 3, + DIGEST_A.to_string(), + full_state_mirror(64), + L2Origin::FromL3, + ) + .expect("insert must fit"); + let hit = tier.get(&k, DIGEST_A).expect("digest match must hit"); + assert_eq!(hit.token_count, 3); + assert_eq!(hit.payload.byte_len(), 64); + // Round-trips into a serving payload with the right byte count. + let payload = hit.payload.to_payload(); + assert_eq!(payload.byte_len(), 64); + assert_eq!( + payload.kind(), + crate::payload::ExactStatePayloadKind::FullState + ); + } + + #[test] + fn digest_mismatch_drops_entry_and_misses() { + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[1, 2, 3]); + tier.insert( + k.clone(), + 3, + DIGEST_A.to_string(), + full_state_mirror(64), + L2Origin::FromL3, + ) + .expect("insert must fit"); + // Wrong expected digest: must be a miss, and the unverifiable entry + // must be gone afterward. + assert!(tier.get(&k, DIGEST_B).is_none()); + assert!(tier.peek(&k).is_none(), "mismatched entry must be dropped"); + let stats = tier.stats(); + assert_eq!(stats.digest_mismatches, 1); + assert_eq!(stats.misses, 1); + assert_eq!(stats.entries, 0); + assert_eq!(stats.bytes, 0); + } + + #[test] + fn budget_evicts_lru_first_and_never_the_protected_entry() { + let tier = L2Tier::new(256); + let k1 = key("ns", &[1]); + let k2 = key("ns", &[2]); + let k3 = key("ns", &[3]); + tier.insert( + k1.clone(), + 1, + DIGEST_A.to_string(), + full_state_mirror(100), + L2Origin::FromL3, + ) + .expect("k1 fits"); + tier.insert( + k2.clone(), + 1, + DIGEST_A.to_string(), + full_state_mirror(100), + L2Origin::FromL3, + ) + .expect("k2 fits"); + // Touch k1 so k2 becomes the LRU victim. + assert!(tier.get(&k1, DIGEST_A).is_some()); + let evictions = tier + .insert( + k3.clone(), + 1, + DIGEST_A.to_string(), + full_state_mirror(100), + L2Origin::FromL3, + ) + .expect("k3 fits after eviction"); + assert_eq!( + evictions.len(), + 1, + "one entry must be evicted: {evictions:?}" + ); + assert_eq!(evictions[0].cache_key, k2, "LRU victim is k2"); + assert_eq!(evictions[0].payload_bytes, 100); + assert!(tier.peek(&k1).is_some(), "recently used k1 survives"); + assert!(tier.peek(&k3).is_some(), "just-inserted k3 survives"); + assert!(tier.peek(&k2).is_none(), "k2 was evicted"); + let stats = tier.stats(); + assert_eq!(stats.evictions, 1); + assert_eq!(stats.bytes, 200, "bytes must track entries exactly"); + } + + #[test] + fn oversized_payload_is_refused_without_evicting() { + let tier = L2Tier::new(128); + let k1 = key("ns", &[1]); + tier.insert( + k1.clone(), + 1, + DIGEST_A.to_string(), + full_state_mirror(64), + L2Origin::FromL3, + ) + .expect("fits"); + let err = tier + .insert( + key("ns", &[2]), + 1, + DIGEST_A.to_string(), + full_state_mirror(129), + L2Origin::FromL3, + ) + .expect_err("over-budget payload must be refused"); + assert_eq!(err, L2InsertRefusal::OverBudget { payload_bytes: 129 }); + assert!(tier.peek(&k1).is_some(), "refusal must not evict anything"); + assert_eq!(tier.stats().refused_bytes, 129); + } + + #[test] + fn empty_payload_is_refused_like_l3() { + let tier = L2Tier::new(1 << 20); + let err = tier + .insert( + key("ns", &[1]), + 1, + DIGEST_A.to_string(), + full_state_mirror(0), + L2Origin::FromL3, + ) + .expect_err("empty payloads must be refused"); + assert_eq!(err, L2InsertRefusal::EmptyPayload); + assert!(tier.is_empty()); + } + + #[test] + fn malformed_digest_is_refused() { + let tier = L2Tier::new(1 << 20); + let err = tier + .insert( + key("ns", &[1]), + 1, + "not-a-digest".to_string(), + full_state_mirror(16), + L2Origin::FromL3, + ) + .expect_err("malformed digest must be refused"); + assert_eq!(err, L2InsertRefusal::MalformedDigest); + } + + #[test] + fn reinsert_replaces_and_keeps_accounting_exact() { + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[9]); + tier.insert( + k.clone(), + 3, + DIGEST_A.to_string(), + full_state_mirror(100), + L2Origin::FromL3, + ) + .expect("first insert"); + let evictions = tier + .insert( + k.clone(), + 3, + DIGEST_B.to_string(), + full_state_mirror(40), + L2Origin::FromL3, + ) + .expect("replacement"); + assert!(evictions.is_empty()); + assert_eq!(tier.len(), 1); + assert_eq!(tier.stats().bytes, 40, "replacement must release old bytes"); + // New digest is the one served now. + assert!(tier.get(&k, DIGEST_B).is_some()); + assert!(tier.get(&k, DIGEST_A).is_none()); + } + + #[test] + fn identical_coordinates_under_different_identities_get_different_keys() { + let a = l2_cache_key("model-a", "state-a", "ns", &[1, 2]); + let b = l2_cache_key("model-b", "state-a", "ns", &[1, 2]); + let c = l2_cache_key("model-a", "state-b", "ns", &[1, 2]); + assert_ne!(a, b); + assert_ne!(a, c); + // Same coordinates, same identities: stable key. + let a2 = l2_cache_key("model-a", "state-a", "ns", &[1, 2]); + assert_eq!(a, a2); + // Different token paths differ. + assert_ne!(a, l2_cache_key("model-a", "state-a", "ns", &[1, 3])); + } + + #[test] + fn capture_round_trips_every_payload_kind() { + let full = crate::payload::ExactStatePayload::full_state(vec![1; 32]); + let rec = crate::payload::ExactStatePayload::recurrent_only(vec![2; 16]); + let kvrec = crate::payload::ExactStatePayload::kv_recurrent(vec![3; 24], vec![4; 8]); + + for payload in [&full, &rec, &kvrec] { + let mirror = ExactStatePayloadMirror::capture(payload); + assert_eq!(mirror.byte_len(), payload.byte_len()); + let rebuilt = mirror.to_payload(); + assert_eq!(rebuilt.byte_len(), payload.byte_len()); + assert_eq!(rebuilt.kind(), payload.kind()); + } + + // Byte-for-byte identity survives the mirror for kv-recurrent. + let mirror = ExactStatePayloadMirror::capture(&kvrec); + let rebuilt = mirror.to_payload(); + let original_kv = kvrec + .kv_bytes() + .expect("kv bytes") + .map(|cow| cow.into_owned()) + .unwrap_or_default(); + let rebuilt_kv = rebuilt + .kv_bytes() + .expect("kv bytes") + .map(|cow| cow.into_owned()) + .unwrap_or_default(); + assert_eq!(original_kv, rebuilt_kv); + } + + #[test] + fn clear_releases_everything_and_reports_bytes() { + let tier = L2Tier::new(1 << 20); + for i in 0..5i32 { + tier.insert( + key("ns", &[i]), + 1, + DIGEST_A.to_string(), + full_state_mirror(64), + L2Origin::FromL3, + ) + .expect("fits"); + } + let released = tier.clear(); + assert_eq!(released, 320); + assert!(tier.is_empty()); + assert_eq!(tier.stats().bytes, 0); + } + + #[test] + fn remove_is_exact() { + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[4]); + tier.insert( + k.clone(), + 1, + DIGEST_A.to_string(), + full_state_mirror(64), + L2Origin::FromL3, + ) + .expect("fits"); + let removed = tier.remove(&k).expect("present entry removes"); + assert_eq!(removed.payload_bytes, 64); + assert!(tier.remove(&k).is_none(), "second remove is None"); + assert_eq!(tier.stats().bytes, 0); + } +} diff --git a/crates/skippy-cache/src/lib.rs b/crates/skippy-cache/src/lib.rs index 724658aaab..01b59e3b2a 100644 --- a/crates/skippy-cache/src/lib.rs +++ b/crates/skippy-cache/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod fsinfo; pub mod identity; +pub mod l2; pub mod l3; pub mod manager; pub mod payload; @@ -16,6 +17,10 @@ pub use identity::{ numerical_model_identity_for_stage, prefix_hash, prefix_hash_with_namespace, prefix_identity, prefix_identity_with_namespace, prefix_namespace_hash, }; +pub use l2::{ + ExactStatePayloadMirror, L2Eviction, L2Hit, L2InsertRefusal, L2Origin, L2Peek, L2Stats, L2Tier, + l2_cache_key, +}; pub use l3::{ GeometryBlock, GeometryKind, HandoffManifest, HandoffSegmentRef, HandoffSegmentStore, MANIFEST_VERSION, ManifestPin, PayloadGeometry, Reservation, SegmentHold, SegmentPut, From 8cc50b07b58399be5aba2f313d041d70c5ba9975 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo <728690+ndizazzo@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:13:32 -0400 Subject: [PATCH 17/41] fix(ui): use advertised capacity for node and mesh VRAM totals (#1746) * label the tile Mesh Capacity and drop the rated meta line * exclude client-role nodes from Mesh Capacity totals * resolve client peers the same way for rows and totals * note why isClientPeer stays separate from resolvePeerRole --- .../app-shell/lib/status-helpers.test.ts | 14 ++- .../features/app-shell/lib/status-helpers.ts | 11 +- .../features/app-tabs/dashboard-fixtures.ts | 2 +- .../chat/lib/live-chat-metrics.test.ts | 87 ++++++++++++++ .../features/chat/lib/live-chat-metrics.ts | 21 ++++ .../src/features/chat/pages/ChatPage.test.tsx | 28 +++++ .../src/features/chat/pages/ChatPage.tsx | 4 +- .../network/api/status-adapter.test.ts | 113 ++++++++++++++++-- .../features/network/api/status-adapter.ts | 31 ++--- .../status/components/StatusStrip.test.tsx | 4 +- crates/mesh-llm-ui/src/lib/vram.test.ts | 76 ++++++++++++ crates/mesh-llm-ui/src/lib/vram.ts | 93 ++++++++++++++ docs/specs/assets/vram-chat-advertised.png | Bin 0 -> 52365 bytes .../assets/vram-dashboard-advertised.png | Bin 0 -> 304481 bytes docs/specs/vram-accounting.md | 27 ++++- 15 files changed, 466 insertions(+), 45 deletions(-) create mode 100644 crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.test.ts create mode 100644 crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.ts create mode 100644 docs/specs/assets/vram-chat-advertised.png create mode 100644 docs/specs/assets/vram-dashboard-advertised.png diff --git a/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.test.ts b/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.test.ts index 93a4b71350..1906bbd52a 100644 --- a/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.test.ts +++ b/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.test.ts @@ -111,11 +111,16 @@ describe('live node state helpers', () => { expect(localRoutableModels({ ...baseStatus, node_state: 'client', is_client: false })).toEqual([]) }) - it('prefers rated GPU inventory over effective capacity for VRAM display', () => { + it('prefers advertised capacity over rated GPU inventory for VRAM totals', () => { const physicalVramBytes = 17_094_934_528 expect(gpuInventoryVramGb([{ vram_bytes: physicalVramBytes }])).toBe(16) - expect(displayVramGb(false, 29.4, [{ vram_bytes: physicalVramBytes }])).toBe(16) + expect(displayVramGb(false, 29.4, [{ vram_bytes: physicalVramBytes }])).toBe(29.4) + }) + + it('falls back to allocatable inventory, then rated class, when no capacity is advertised', () => { + expect(displayVramGb(false, 0, [{ vram_bytes: 32_000_000_000, reserved_bytes: 1_000_000_000 }])).toBe(31) + expect(displayVramGb(false, undefined, [{ rated_vram_gb: 24 }])).toBe(24) }) it('prefers explicit rated VRAM when formatting GPU inventory', () => { @@ -145,7 +150,7 @@ describe('live node state helpers', () => { expect(overviewVramGb(true, 12.5)).toBe(0) }) - it('uses physical GPU inventory for mesh VRAM totals when available', () => { + it('sums advertised capacity for mesh VRAM totals, matching /api/status', () => { const status: StatusPayload = { node_id: 'local-node', node_status: 'Serving', @@ -187,6 +192,7 @@ describe('live node state helpers', () => { wakeable_nodes: [] } - expect(meshGpuVram(status)).toBe(36) + // 29.4 local + 48 + 8 advertised, not the 16 + 12 rated inventory sum. + expect(meshGpuVram(status)).toBeCloseTo(85.4, 6) }) }) diff --git a/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.ts b/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.ts index ba58264152..9ba4f8ad47 100644 --- a/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.ts +++ b/crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.ts @@ -14,9 +14,10 @@ import { peerLatencyHint as formatPeerLatencyHint, formatPeerLatencySummary as formatPeerLatencySummaryFn } from '@/lib/format-latency' -import { formatRatedVramBytes, formatRatedVramGB, gpuRatedVramGB } from '@/lib/vram' +import { formatRatedVramBytes, formatRatedVramGB, gpuRatedVramGB, nodeAdvertisedVramGB } from '@/lib/vram' +import type { VramGpuInput } from '@/lib/vram' -type GpuInventoryItem = { total_vram_gb?: number; rated_vram_gb?: number; vram_bytes?: number } +type GpuInventoryItem = VramGpuInput type GpuInventory = GpuInventoryItem[] export function modelDisplayName(model?: MeshModel | null) { @@ -67,9 +68,13 @@ export function gpuInventoryVramGb(gpus?: GpuInventory | null) { return total > 0 ? total : null } +/** + * Capacity a node contributes to mesh totals: the advertised figure first (what + * `/api/status` and the scheduler use), then allocatable inventory, then the rated class. + */ export function displayVramGb(isClient: boolean, capacityVramGb?: number | null, gpus?: GpuInventory | null) { if (isClient) return 0 - return gpuInventoryVramGb(gpus) ?? overviewVramGb(false, capacityVramGb) + return nodeAdvertisedVramGB({ vram_gb: capacityVramGb, gpus }) ?? 0 } function assertLiveNodeState(state: LiveNodeState | undefined | null): LiveNodeState | null { diff --git a/crates/mesh-llm-ui/src/features/app-tabs/dashboard-fixtures.ts b/crates/mesh-llm-ui/src/features/app-tabs/dashboard-fixtures.ts index 615b7609a3..0f32778167 100644 --- a/crates/mesh-llm-ui/src/features/app-tabs/dashboard-fixtures.ts +++ b/crates/mesh-llm-ui/src/features/app-tabs/dashboard-fixtures.ts @@ -28,7 +28,7 @@ export const STATUS_METRICS: StatusMetric[] = [ { id: 'mesh-vram', icon: metricIcon(HardDrive), - label: 'Mesh VRAM', + label: 'Mesh Capacity', value: '160.5', unit: 'GB', meta: '57% free', diff --git a/crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.test.ts b/crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.test.ts new file mode 100644 index 0000000000..6b2b31d8a8 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { liveChatActionMetrics } from '@/features/chat/lib/live-chat-metrics' +import type { StatusPayload } from '@/lib/api/types' + +function status(overrides: Partial): StatusPayload { + return { + node_id: 'local-node', + node_state: 'serving', + model_name: 'model', + peers: [], + models: [], + my_vram_gb: 0, + gpus: [], + serving_models: [], + ...overrides + } +} + +describe('liveChatActionMetrics', () => { + it('returns nothing until live status has loaded', () => { + expect(liveChatActionMetrics(undefined)).toEqual([]) + }) + + it('counts every node and sums the capacity each one advertises, not the rated GPU class', () => { + const metrics = liveChatActionMetrics( + status({ + my_vram_gb: 115.448725504, + gpus: [{ name: 'Apple M4 Max', rated_vram_gb: 128, vram_bytes: 115_448_725_504 }], + peers: [ + { + id: 'carrack', + role: 'Host', + state: 'serving', + models: [], + vram_gb: 44.02970624, + gpus: [ + { name: 'RTX 5090', rated_vram_gb: 32, vram_bytes: 34_190_917_632 }, + { name: 'RTX 3080', rated_vram_gb: 10, vram_bytes: 10_737_418_240 } + ] + } + ] + }) + ) + + expect(metrics).toEqual([ + { id: 'nodes', icon: 'cpu', label: '2 nodes' }, + { id: 'vram', icon: 'hard-drive', label: '159.5 GB' } + ]) + }) + + it('uses the singular label for a lone node', () => { + expect(liveChatActionMetrics(status({ my_vram_gb: 24 }))).toEqual([ + { id: 'nodes', icon: 'cpu', label: '1 node' }, + { id: 'vram', icon: 'hard-drive', label: '24.0 GB' } + ]) + }) + + it('counts client nodes but excludes their capacity, like the scheduler does', () => { + const metrics = liveChatActionMetrics( + status({ + my_vram_gb: 115.4, + peers: [ + { id: 'host', role: 'Host', state: 'serving', models: [], vram_gb: 44 }, + { id: 'laptop', role: 'Client', state: 'client', models: [], vram_gb: 24 } + ] + }) + ) + + expect(metrics).toEqual([ + { id: 'nodes', icon: 'cpu', label: '3 nodes' }, + { id: 'vram', icon: 'hard-drive', label: '159.4 GB' } + ]) + }) + + it('suppresses local capacity when this node is a client', () => { + const metrics = liveChatActionMetrics( + status({ + node_state: 'client', + my_vram_gb: 115.4, + peers: [{ id: 'host', role: 'Host', state: 'serving', models: [], vram_gb: 44 }] + }) + ) + + expect(metrics[1]).toEqual({ id: 'vram', icon: 'hard-drive', label: '44.0 GB' }) + }) +}) diff --git a/crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.ts b/crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.ts new file mode 100644 index 0000000000..c07123b5f3 --- /dev/null +++ b/crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.ts @@ -0,0 +1,21 @@ +import type { ChatActionMetric } from '@/features/app-tabs/types' +import type { StatusPayload } from '@/lib/api/types' +import { meshAdvertisedVramGB, meshCapacityInputFromStatus } from '@/lib/vram' + +/** + * Header badges for live mode: node count and advertised mesh capacity, derived + * from the same status payload and helper the Network tab uses so both tabs agree. + * Client-role nodes are counted as nodes but contribute no capacity, matching + * the scheduler's aggregate. + */ +export function liveChatActionMetrics(status: StatusPayload | undefined): ChatActionMetric[] { + if (!status) return [] + + const nodeCount = 1 + (status.peers?.length ?? 0) + const vramGb = meshAdvertisedVramGB(meshCapacityInputFromStatus(status)) + + return [ + { id: 'nodes', icon: 'cpu', label: `${nodeCount} node${nodeCount === 1 ? '' : 's'}` }, + { id: 'vram', icon: 'hard-drive', label: `${vramGb.toFixed(1)} GB` } + ] +} diff --git a/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.test.tsx b/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.test.tsx index be64ad35e7..102eba83cb 100644 --- a/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.test.tsx +++ b/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.test.tsx @@ -224,6 +224,34 @@ describe('ChatPage', () => { expect(screen.getByRole('option', { name: /peer-model/ })).toBeVisible() }) + it('shows live node count and advertised mesh capacity in the header instead of harness fixtures', () => { + vi.mocked(useModelsQuery).mockReturnValue({ + data: undefined, + isFetching: false, + isError: false, + refetch: vi.fn() + } as unknown as ReturnType) + vi.mocked(useStatusQuery).mockReturnValue({ + data: { + llama_ready: true, + node_state: 'serving', + my_vram_gb: 115.448725504, + gpus: [{ name: 'Apple M4 Max', rated_vram_gb: 128, vram_bytes: 115_448_725_504 }], + serving_models: ['local-model'], + peers: [{ state: 'serving', vram_gb: 44.02970624, hosted_models: ['peer-model'], hosted_models_known: true }] + }, + isFetching: false, + isError: false, + refetch: vi.fn() + } as unknown as ReturnType) + + renderChatPage({ mode: 'live' }) + + expect(screen.getByText('2 nodes')).toBeInTheDocument() + expect(screen.getByText('159.5 GB')).toBeInTheDocument() + expect(screen.queryByText('61.7 GB')).not.toBeInTheDocument() + }) + it('keeps live chat usable when catalog enrichment fails but runtime status is ready', () => { vi.mocked(useModelsQuery).mockReturnValue({ data: undefined, diff --git a/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.tsx b/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.tsx index 5045b2e6b7..cbe85b97f3 100644 --- a/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.tsx +++ b/crates/mesh-llm-ui/src/features/chat/pages/ChatPage.tsx @@ -16,6 +16,7 @@ import { adaptModelsToSummary } from '@/features/network/api/models-adapter' import { useDataMode } from '@/lib/data-mode' import { useBooleanFeatureFlag } from '@/lib/feature-flags' import { CHAT_HARNESS } from '@/features/app-tabs/data' +import { liveChatActionMetrics } from '@/features/chat/lib/live-chat-metrics' import { statusBackedChatModels } from '@/features/chat/lib/live-chat-models' import type { ChatHarnessData, Conversation, ModelSelectOption, TransparencyMessage } from '@/features/app-tabs/types' import { @@ -59,6 +60,7 @@ export function ChatPageContent({ data = CHAT_HARNESS }: ChatPageProps) { [modelsQuery.data] ) const statusModels = useMemo(() => statusBackedChatModels(liveStatus), [liveStatus]) + const liveActionMetrics = useMemo(() => liveChatActionMetrics(liveStatus), [liveStatus]) const liveModels = catalogModels && catalogModels.length > 0 ? catalogModels : statusModels const displayModels = liveMode ? liveModels : data.models const selectableModels = useMemo(() => displayModels.filter(isChatSelectableModel), [displayModels]) @@ -736,7 +738,7 @@ export function ChatPageContent({ data = CHAT_HARNESS }: ChatPageProps) { onAttachmentPreviewOpenChange={(open) => { if (!open) setSelectedAttachmentPreview(null) }} - actionMetrics={data.actionMetrics} + actionMetrics={liveMode ? liveActionMetrics : data.actionMetrics} modelLabel={data.modelLabel} modelOptions={options} selectedModelValue={selectedModelValue} diff --git a/crates/mesh-llm-ui/src/features/network/api/status-adapter.test.ts b/crates/mesh-llm-ui/src/features/network/api/status-adapter.test.ts index a76ef794c4..4609ac35c8 100644 --- a/crates/mesh-llm-ui/src/features/network/api/status-adapter.test.ts +++ b/crates/mesh-llm-ui/src/features/network/api/status-adapter.test.ts @@ -166,25 +166,42 @@ describe('adaptStatusToDashboard', () => { ) }) - it('uses per-GPU rated VRAM for mesh aggregate display before legacy totals', () => { + it('uses advertised capacity for mesh VRAM so the headline matches /api/status and doctor split', () => { const dashboard = adaptStatusToDashboard({ ...PUBLIC_STATUS_PAYLOAD, - my_vram_gb: 30.15, - gpus: [{ idx: 0, name: 'local-gpu', total_vram_gb: 30.15, rated_vram_gb: 32, vram_bytes: 32_000_000_000 }], + my_vram_gb: 115.448725504, + gpus: [ + { + idx: 0, + name: 'Apple M4 Max', + rated_vram_gb: 128, + vram_bytes: 115_448_725_504, + allocatable_vram_bytes: 115_448_725_504 + } + ], peers: [ { id: 'remote-serving', role: 'Host', state: 'serving', models: [], - vram_gb: 20, + vram_gb: 44.02970624, gpus: [ { idx: 0, - name: 'remote-gpu', - total_vram_gb: 22.35, - rated_vram_gb: 24, - vram_bytes: 24_000_000_000 + name: 'NVIDIA GeForce RTX 5090', + rated_vram_gb: 32, + vram_bytes: 34_190_917_632, + reserved_bytes: 514_850_816, + allocatable_vram_bytes: 33_676_066_816 + }, + { + idx: 1, + name: 'NVIDIA GeForce RTX 3080', + rated_vram_gb: 10, + vram_bytes: 10_737_418_240, + reserved_bytes: 383_778_816, + allocatable_vram_bytes: 10_353_639_424 } ], hostname: 'remote-serving' @@ -192,12 +209,86 @@ describe('adaptStatusToDashboard', () => { ] }) + // 115.4 + 44.0 advertised, not the 128 + 32 + 10 = 170 rated sum. expect(dashboard.statusMetrics.find((metric) => metric.id === 'mesh-vram')).toEqual( - expect.objectContaining({ value: '56.0', unit: 'GB' }) + expect.objectContaining({ value: '159.5', unit: 'GB' }) + ) + expect(dashboard.statusMetrics.find((metric) => metric.id === 'mesh-vram')).not.toHaveProperty('meta') + expect(dashboard.peers.find((peer) => peer.id === '16ce0bb4de')).toEqual( + expect.objectContaining({ vramGB: 115.448725504 }) ) - expect(dashboard.peers.find((peer) => peer.id === '16ce0bb4de')).toEqual(expect.objectContaining({ vramGB: 32 })) expect(dashboard.peers.find((peer) => peer.id === 'remote-serving')).toEqual( - expect.objectContaining({ vramGB: 24 }) + expect.objectContaining({ vramGB: 44.02970624 }) + ) + expect(dashboard.peerSummary.capacity).toBe('159 GB') + }) + + it('excludes client-role nodes from Mesh Capacity even when they advertise VRAM', () => { + const dashboard = adaptStatusToDashboard({ + ...PUBLIC_STATUS_PAYLOAD, + my_vram_gb: 115.4, + peers: [ + { id: 'host-peer', role: 'Host', state: 'serving', models: [], vram_gb: 44, hostname: 'host-peer' }, + { id: 'client-peer', role: 'Client', state: 'client', models: [], vram_gb: 24, hostname: 'client-peer' }, + { + id: 'client-by-node-state', + role: 'Worker', + node_state: 'client', + models: [], + vram_gb: 16, + hostname: 'client-by-node-state' + } + ] + }) + + expect(dashboard.statusMetrics.find((metric) => metric.id === 'mesh-vram')).toEqual( + expect.objectContaining({ value: '159.4', unit: 'GB' }) + ) + expect(dashboard.peers.find((peer) => peer.id === 'client-peer')).toEqual(expect.objectContaining({ vramGB: 0 })) + expect(dashboard.peers.find((peer) => peer.id === 'client-by-node-state')).toEqual( + expect.objectContaining({ vramGB: 0 }) + ) + expect(dashboard.peerSummary.capacity).toBe('159 GB') + }) + + it('suppresses local capacity in Mesh Capacity when this node is a client', () => { + const dashboard = adaptStatusToDashboard({ + ...PUBLIC_STATUS_PAYLOAD, + node_state: 'client', + my_vram_gb: 115.4, + peers: [{ id: 'host-peer', role: 'Host', state: 'serving', models: [], vram_gb: 44, hostname: 'host-peer' }] + }) + + expect(dashboard.statusMetrics.find((metric) => metric.id === 'mesh-vram')).toEqual( + expect.objectContaining({ value: '44.0', unit: 'GB' }) + ) + expect(dashboard.peers.find((peer) => peer.id === '16ce0bb4de')).toEqual(expect.objectContaining({ vramGB: 0 })) + }) + + it('falls back to allocatable GPU inventory, then rated class, when a node advertises no capacity', () => { + const dashboard = adaptStatusToDashboard({ + ...PUBLIC_STATUS_PAYLOAD, + my_vram_gb: 0, + gpus: [ + { idx: 0, name: 'local-gpu', rated_vram_gb: 32, vram_bytes: 32_000_000_000, reserved_bytes: 1_000_000_000 } + ], + peers: [ + { + id: 'legacy-peer', + role: 'Host', + state: 'serving', + models: [], + vram_gb: 0, + gpus: [{ idx: 0, name: 'legacy-gpu', rated_vram_gb: 24 }], + hostname: 'legacy-peer' + } + ] + }) + + expect(dashboard.peers.find((peer) => peer.id === '16ce0bb4de')).toEqual(expect.objectContaining({ vramGB: 31 })) + expect(dashboard.peers.find((peer) => peer.id === 'legacy-peer')).toEqual(expect.objectContaining({ vramGB: 24 })) + expect(dashboard.statusMetrics.find((metric) => metric.id === 'mesh-vram')).toEqual( + expect.objectContaining({ value: '55.0', unit: 'GB' }) ) }) diff --git a/crates/mesh-llm-ui/src/features/network/api/status-adapter.ts b/crates/mesh-llm-ui/src/features/network/api/status-adapter.ts index b94eacac6a..7eb26e583f 100644 --- a/crates/mesh-llm-ui/src/features/network/api/status-adapter.ts +++ b/crates/mesh-llm-ui/src/features/network/api/status-adapter.ts @@ -1,7 +1,7 @@ import { DASHBOARD_HARNESS } from '@/features/app-tabs/data' -import type { StatusPayload, PeerInfo, GpuInfo, ServingModelEntry } from '@/lib/api/types' +import type { StatusPayload, PeerInfo, ServingModelEntry } from '@/lib/api/types' import { isPublicMesh } from '@/lib/api/mesh-visibility' -import { gpuRatedVramGB } from '@/lib/vram' +import { isClientPeer, meshAdvertisedVramGB, meshCapacityInputFromStatus, nodeAdvertisedVramGB } from '@/lib/vram' import type { DashboardHarnessData, DashboardConnectData, @@ -115,23 +115,16 @@ function finiteMetric(value: number | undefined): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } -function gpuTotalVramGb(gpus?: GpuInfo[]): number | null { - if (!gpus?.length) return null - - const total = gpus.reduce((sum, gpu) => { - return sum + (gpuRatedVramGB(gpu) ?? 0) - }, 0) - - return total > 0 ? total : null -} - +// Node and mesh VRAM figures use the capacity each node advertises to the mesh, +// so the dashboard agrees with `/api/status`, `doctor split`, and the scheduler. +// Per-GPU labels elsewhere keep the rated class (see docs/specs/vram-accounting.md). function peerVramGb(peer: PeerInfo): number { - return finiteMetric(gpuTotalVramGb(peer.gpus) ?? peer.my_vram_gb ?? peer.vram_gb ?? undefined) + return finiteMetric(nodeAdvertisedVramGB({ ...peer, client: isClientPeer(peer) }) ?? undefined) } -function meshTotalVramGb(payload: StatusPayload): number { - const localVram = finiteMetric(gpuTotalVramGb(payload.gpus) ?? payload.my_vram_gb ?? undefined) - return payload.peers.reduce((sum, peer) => sum + peerVramGb(peer), localVram) +function selfVramGb(payload: StatusPayload): number { + const { peers: _peers, ...self } = meshCapacityInputFromStatus(payload) + return finiteMetric(nodeAdvertisedVramGB(self) ?? undefined) } function resolveInflightRequests(payload: StatusPayload): number { @@ -197,7 +190,7 @@ function adaptSelfPeer(payload: StatusPayload): Peer { role: 'you' as const, nodeState: effectiveState, version: payload.version, - vramGB: gpuTotalVramGb(payload.gpus) ?? payload.my_vram_gb, + vramGB: selfVramGb(payload), toksPerSec: payload.tok_per_sec, firstJoinedMeshTs: payload.first_joined_mesh_ts } @@ -232,7 +225,7 @@ function adaptStatusMetrics(payload: StatusPayload): StatusMetric[] { ]) const remoteServingModelNames = normalizeModelList(payload.peers.flatMap(resolveHostedModels)) const activeModelNames = normalizeModelList([...localServingModelNames, ...remoteServingModelNames]) - const totalMeshVram = meshTotalVramGb(payload) + const totalMeshVram = meshAdvertisedVramGB(meshCapacityInputFromStatus(payload)) const peerCount = payload.peers.length const inflightRequests = resolveInflightRequests(payload) const owner = resolveOwner(payload.owner) ?? 'Unsigned' @@ -274,7 +267,7 @@ function adaptStatusMetrics(payload: StatusPayload): StatusMetric[] { }, { id: 'mesh-vram', - label: 'Mesh VRAM', + label: 'Mesh Capacity', value: totalMeshVram.toFixed(1), unit: 'GB' }, diff --git a/crates/mesh-llm-ui/src/features/status/components/StatusStrip.test.tsx b/crates/mesh-llm-ui/src/features/status/components/StatusStrip.test.tsx index 914bb237d1..0877014a91 100644 --- a/crates/mesh-llm-ui/src/features/status/components/StatusStrip.test.tsx +++ b/crates/mesh-llm-ui/src/features/status/components/StatusStrip.test.tsx @@ -32,7 +32,7 @@ describe('StatusStrip', () => { { id: 'node-id', label: 'Node ID', value: 'abc123' }, { id: 'owner', label: 'Owner', value: 'Unsigned' }, { id: 'active-models', label: 'Active models', value: 1 }, - { id: 'mesh-vram', label: 'Mesh VRAM', value: '32.5', unit: 'GB' }, + { id: 'mesh-vram', label: 'Mesh Capacity', value: '32.5', unit: 'GB' }, { id: 'nodes', label: 'Nodes', value: 2 }, { id: 'inflight', label: 'Inflight', value: 3 } ]} @@ -48,7 +48,7 @@ describe('StatusStrip', () => { diff --git a/crates/mesh-llm-ui/src/lib/vram.test.ts b/crates/mesh-llm-ui/src/lib/vram.test.ts index b36138f002..dcc05aa3fd 100644 --- a/crates/mesh-llm-ui/src/lib/vram.test.ts +++ b/crates/mesh-llm-ui/src/lib/vram.test.ts @@ -7,6 +7,11 @@ import { gpuRatedVramGB, gpuReservedVramGB, gpuSystemReportedVramGB, + meshAdvertisedVramGB, + isClientPeer, + meshCapacityInputFromStatus, + nodeAdvertisedVramGB, + nodeRatedVramGB, ratedVramGBFromBytes } from '@/lib/vram' @@ -41,4 +46,75 @@ describe('VRAM accounting utilities', () => { expect(allocatableVramBytes(1_000, 400)).toBe(600) expect(allocatableVramBytes(1_000, 1_400)).toBe(0) }) + + it('prefers the advertised capacity a node announces to the mesh over its GPU inventory', () => { + const node = { + vram_gb: 44.02970624, + gpus: [ + { rated_vram_gb: 32, vram_bytes: 34_190_917_632, reserved_bytes: 514_850_816 }, + { rated_vram_gb: 10, vram_bytes: 10_737_418_240, reserved_bytes: 383_778_816 } + ] + } + + expect(nodeAdvertisedVramGB(node)).toBe(44.02970624) + expect(nodeRatedVramGB(node)).toBe(42) + }) + + it('falls back to allocatable inventory, then the rated class, when nothing is advertised', () => { + const withReserve = { vram_gb: 0, gpus: [{ vram_bytes: 32_000_000_000, reserved_bytes: 1_000_000_000 }] } + expect(nodeAdvertisedVramGB(withReserve)).toBe(31) + + const ratedOnly = { gpus: [{ rated_vram_gb: 24 }] } + expect(nodeAdvertisedVramGB(ratedOnly)).toBe(24) + + expect(nodeAdvertisedVramGB({ vram_gb: 0, gpus: [] })).toBeNull() + }) + + it('reads legacy my_vram_gb when vram_gb is absent', () => { + expect(nodeAdvertisedVramGB({ my_vram_gb: 12.5 })).toBe(12.5) + }) + + it('sums advertised capacity across the local node and peers', () => { + const mesh = { + vram_gb: 115.448725504, + gpus: [{ rated_vram_gb: 128, vram_bytes: 115_448_725_504 }], + peers: [{ vram_gb: 44.02970624, gpus: [{ rated_vram_gb: 32 }, { rated_vram_gb: 10 }] }] + } + + expect(meshAdvertisedVramGB(mesh)).toBeCloseTo(159.478, 3) + }) + + it('leaves client-role nodes out of mesh totals even when they advertise capacity', () => { + expect(nodeAdvertisedVramGB({ vram_gb: 24, client: true })).toBeNull() + + const input = meshCapacityInputFromStatus({ + my_vram_gb: 115.4, + node_state: 'serving', + peers: [ + { vram_gb: 44, state: 'serving', role: 'Host' }, + { vram_gb: 24, state: 'client', role: 'Client' }, + { vram_gb: 16, state: 'serving', role: 'Client' } + ] + }) + + expect(meshAdvertisedVramGB(input)).toBeCloseTo(159.4, 6) + }) + + it('suppresses the local node capacity when this node is a client', () => { + expect( + meshAdvertisedVramGB( + meshCapacityInputFromStatus({ my_vram_gb: 115.4, is_client: true, peers: [{ vram_gb: 44 }] }) + ) + ).toBe(44) + expect( + meshAdvertisedVramGB(meshCapacityInputFromStatus({ my_vram_gb: 115.4, node_state: 'client', peers: [] })) + ).toBe(0) + }) + + it('recognises a client peer from node_state, state, or role', () => { + expect(isClientPeer({ node_state: 'client' })).toBe(true) + expect(isClientPeer({ state: 'client' })).toBe(true) + expect(isClientPeer({ role: 'Client' })).toBe(true) + expect(isClientPeer({ node_state: 'serving', role: 'Host' })).toBe(false) + }) }) diff --git a/crates/mesh-llm-ui/src/lib/vram.ts b/crates/mesh-llm-ui/src/lib/vram.ts index 9064aad67d..952b856f1a 100644 --- a/crates/mesh-llm-ui/src/lib/vram.ts +++ b/crates/mesh-llm-ui/src/lib/vram.ts @@ -84,3 +84,96 @@ export function formatRatedVramGB(valueGB: number | null | undefined): string { export function formatRatedVramBytes(bytes: number | null | undefined): string { return formatRatedVramGB(ratedVramGBFromBytes(bytes)) } + +export type VramNodeInput = { + vram_gb?: number | null + my_vram_gb?: number | null + gpus?: VramGpuInput[] | null + /** Client-role nodes consume capacity but never serve, so they contribute nothing to mesh totals. */ + client?: boolean +} + +export type VramMeshInput = VramNodeInput & { + peers?: VramNodeInput[] | null +} + +function sumGpuVramGB( + gpus: VramGpuInput[] | null | undefined, + pick: (gpu: VramGpuInput) => number | null +): number | null { + if (!gpus?.length) return null + const total = gpus.reduce((sum, gpu) => sum + (pick(gpu) ?? 0), 0) + return total > 0 ? total : null +} + +/** + * Rated capacity class summed across a node's GPU inventory (marketing GB, e.g. 32 + 10 = 42). + * Display-only; never use it for fit math or mesh totals. + */ +export function nodeRatedVramGB(node: VramNodeInput): number | null { + return sumGpuVramGB(node.gpus, gpuRatedVramGB) +} + +/** + * Capacity the node advertises to the mesh, in decimal GB. This is the figure + * `/api/status` reports as `my_vram_gb` / `vram_gb` and the one the scheduler and + * `doctor split` sum, so aggregates built from it agree with the API and CLI. + * Falls back to per-GPU allocatable bytes, then to the rated class, only for + * legacy payloads that carry no announced value. Client-role nodes yield null: + * the scheduler excludes them from aggregate capacity, so the console must too. + */ +export function nodeAdvertisedVramGB(node: VramNodeInput): number | null { + if (node.client) return null + return ( + finitePositive(node.vram_gb) ?? + finitePositive(node.my_vram_gb) ?? + sumGpuVramGB(node.gpus, gpuAllocatableVramGB) ?? + nodeRatedVramGB(node) + ) +} + +/** Advertised capacity of the local node plus every peer, in decimal GB. */ +export function meshAdvertisedVramGB(mesh: VramMeshInput): number { + const local = nodeAdvertisedVramGB(mesh) ?? 0 + return (mesh.peers ?? []).reduce((sum, peer) => sum + (nodeAdvertisedVramGB(peer) ?? 0), local) +} + +/** Minimal shape of `/api/status` needed to build mesh capacity totals. */ +export type VramStatusLike = { + my_vram_gb?: number | null + gpus?: VramGpuInput[] | null + is_client?: boolean + node_state?: string + peers?: Array | null +} + +/** + * Capacity rule for peers: any of the three fields can mark a client. This is + * intentionally separate from the dashboard's `resolvePeerRole`, which is a + * display rule that returns `host` before it looks at state. Keep the two apart; + * rows and totals must both call this one so they cannot disagree. The live API + * emits `state` and `role` on peers; `node_state` is honoured only because the + * UI's `PeerInfo` type and the adapter's state resolution already accept it. + */ +export function isClientPeer(peer: { node_state?: string; state?: string; role?: string }): boolean { + return peer.node_state === 'client' || peer.state === 'client' || peer.role?.toLowerCase() === 'client' +} + +/** + * Builds the capacity input for a status payload, marking the local node and + * any peer in the client role so they are left out of mesh totals. Both the + * dashboard and the chat header go through this so they agree by construction. + */ +export function meshCapacityInputFromStatus(status: VramStatusLike): VramMeshInput { + return { + vram_gb: status.my_vram_gb, + gpus: status.gpus, + client: status.is_client === true || status.node_state === 'client', + peers: (status.peers ?? []).map((peer) => ({ + vram_gb: peer.vram_gb, + my_vram_gb: peer.my_vram_gb, + gpus: peer.gpus, + client: isClientPeer(peer) + })) + } +} diff --git a/docs/specs/assets/vram-chat-advertised.png b/docs/specs/assets/vram-chat-advertised.png new file mode 100644 index 0000000000000000000000000000000000000000..a1de84434200b066211b107040cfd310a24533dc GIT binary patch literal 52365 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 literal 0 HcmV?d00001 diff --git a/docs/specs/assets/vram-dashboard-advertised.png b/docs/specs/assets/vram-dashboard-advertised.png new file mode 100644 index 0000000000000000000000000000000000000000..06f2e9f3d7cc60ad590e534749b2655a6fc60a93 GIT binary patch literal 304481 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�PO-)@GAL2_|ez*&C8q~IOmLkoPrS_ul{C7N@= zk$E7XImgmz#C?Fd5Bw>AQu|QDM2kfw=8Z1YiqSJc$2(lj5+$}Vqkr$09ilB`c!Xd* zm$68pe3kuXiDq}4JK*VXLBJ;c{|ByYQv-(Vlff`D2-~R}Za?B!`{ZiTpz~&F*_P~m z{81i=t_!pz=?pKYu06#u`IwC}O~ zx*0J83=O)AE=8J|+3#$DaOu@*`iWR)8K(C)lVp;e13(7K=F$zK6@&^=h)?S3RSOdT zE=0tB`pQ@~Kt_cNKU)BTzA-TOwh8stv#n=iKK0jXcT$ z6t^-I-sTj}!@TFa9Z#H6M<)buT9M*3xeScR7W}xK>q+#N_RDb%6~}+bz=a4 zvz_p73g4dqz;Z!#QR3QQ`xEu8B{=T4Kuii4&vUBIDmy&j|A;T|e`c?(bL8u29H^z8 zoric4h-F%|#00C=I9e{sVh4cM_BdzDwUT7gf;20?(Q9UmfO4?Y`0fZ8G$z-M`$E+J zGPYPFD)mD*$89#KLG100W@?|+-?fg;*Fcx7H_II->9LvNzhs+XuczcA)AqCutFr{s zgOyIh9;8x4Lldbg3`o5nT4L&fRv^_=H6I1)bmUiQ1ZqH~B9F=u=@FoJ5P|zk0{r%e z?_ct30EQW#`&R(pT*>E__u6<<6T;DGLlD>p08%BG^$R)h0_8ujA`aInDSDf@bEnnn zgdIJ(GFdk`e8H$dX_raZ7;=KiyN|leEYH5AePoa?AUZ$umxNFlnpG5ud~q5I=dCcK zHW9%<7FazId#%HkR8UF)?_D{~q{P{J)gjHoZG{1-I>v`8&psxgRW#6E`pvQc(Bd52 zM#`|eHpvV1z@>NlZfKL11`@u$ik^CL{+akpgbqNzN5e7juzCe4ULA*qMf05~N=t`?einWBi z={51QsoFED)QmQb-=+;Zi7J83bl_IBHJ$ki88ZEMb7DGG!7&G9bSi^>C+kuhD#d~I z^eE*kjn)53A_E}zU*LU?k1F>T6`HEB!Dqm zkthv)vsBZ32rBSGGag19L$y67@kb=W)R`$oz)c@a7;L&!>Bv72g#dmUzc~!;Q|}pD z9QwQ*oR{2s9L%z>UP3kv8@cvg8R!j@ZWIb8jzs|p_Q)iquYF}wX8$M9B**xV%6HQD zF*j@?eZI&i!O0y_C#2r*y!*%=1QiSvL1-=T`cCL5Z~@^e2*b=a3S&@s(&Qbz^i_bN zJ(l;b_#NR%f6c!lNcn|boDFb_ⅇ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

?; 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 OS1fEV6<>7y^S=P+@zLM_ literal 0 HcmV?d00001 diff --git a/docs/specs/vram-accounting.md b/docs/specs/vram-accounting.md index 07df374704..f7d58f4960 100644 --- a/docs/specs/vram-accounting.md +++ b/docs/specs/vram-accounting.md @@ -10,9 +10,27 @@ mesh-llm uses three VRAM concepts: platform reports them. Live used-memory counters are not reserved VRAM. Internal fit decisions should use `system_reported_bytes - reserved_bytes` -where a true reserved value is available. User-facing labels should show the +where a true reserved value is available. Per-GPU labels should show the rated capacity class. +Node and mesh totals are a different case. A node advertises one capacity to +the mesh (`PeerAnnouncement.vram_bytes`, reported by `/api/status` as +`my_vram_gb` and `peers[].vram_gb`), and that is the number the scheduler, +`doctor split` (`aggregate_capacity_bytes`), and model-target advice sum. Any +surface that presents a node or mesh total (the dashboard `Mesh Capacity` tile, the +peer table `VRAM` column, the chat header) must use that advertised figure so +the console, the CLI, and the API agree. Client-role nodes advertise capacity +but never serve, and the scheduler excludes them from the aggregate, so totals +exclude them as well. Summing rated classes across GPUs is display-only and +overstates schedulable capacity; the UI helpers fall back to allocatable, then +rated, inventory only for legacy payloads that carry no advertised value, never +as the primary source of a total (see #1656 for the itemized total / reserved / +usable breakdown that will replace the single value). + +![Dashboard showing Mesh Capacity 115.4 GB from the advertised capacity](assets/vram-dashboard-advertised.png) + +![Chat header showing 1 node and 115.4 GB from live status](assets/vram-chat-advertised.png) + | Location | Value source | Classification | Current use | |---|---|---|---| | `crates/mesh-llm-system/src/hardware/mod.rs` | platform tools, Skippy devices, system RAM fallback | internal source | Builds `HardwareSurvey.vram_bytes`, per-GPU `gpu_vram`, and `gpu_reserved`. | @@ -28,9 +46,10 @@ rated capacity class. | `crates/mesh-llm-host-runtime/src/runtime/context_planning.rs` | local/split capacity bytes | internal | Computes KV/context budget from capacity after model bytes. | | `crates/mesh-llm-host-runtime/src/api/model_target_capacity.rs` | local and peer `vram_bytes` | internal/API advice | Computes fit summaries and capacity advice. | | `crates/mesh-llm-host-runtime/src/runtime_data/collector.rs` | peer `vram_bytes` | API/user-facing aggregate | Produces mesh and peer VRAM summaries for status views. | -| `crates/mesh-llm-ui/src/lib/vram.ts` | status GPU fields | shared UI semantic utility | Computes rated, system-reported, reserved, and allocatable values for UI components. | -| `crates/mesh-llm-ui/src/features/network/api/status-adapter.ts` | `/api/status` | user-facing dashboard | Prefers GPU inventory rated capacity for displayed mesh/node VRAM. | -| `crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.ts` | `/api/status` and topology data | user-facing dashboard helpers | Formats GPU inventory with rated capacity. | +| `crates/mesh-llm-ui/src/lib/vram.ts` | status GPU and node fields | shared UI semantic utility | Computes rated, system-reported, reserved, and allocatable values per GPU, and advertised node and mesh totals (`nodeAdvertisedVramGB`, `meshAdvertisedVramGB`). | +| `crates/mesh-llm-ui/src/features/network/api/status-adapter.ts` | `/api/status` | user-facing dashboard | Node rows and the `Mesh Capacity` tile use advertised capacity (`my_vram_gb` / `vram_gb`), falling back to allocatable then rated inventory only when nothing is advertised. | +| `crates/mesh-llm-ui/src/features/app-shell/lib/status-helpers.ts` | `/api/status` and topology data | user-facing dashboard helpers | Formats GPU inventory with rated capacity; node and mesh totals (`displayVramGb`, `meshGpuVram`) use advertised capacity. | +| `crates/mesh-llm-ui/src/features/chat/lib/live-chat-metrics.ts` | `/api/status` | user-facing chat header | Node count and advertised mesh capacity badges, from the same helper as the dashboard so both tabs agree. | | `crates/mesh-llm-ui/src/features/configuration/api/config-adapter.ts` | `/api/status.gpus[]` | bridge from API to UI math | Maps rated total, system total, reserved, and allocatable fields into config nodes. | | `crates/mesh-llm-ui/src/features/configuration/lib/config-math.ts` | config node GPU fields | internal UI calculation | Uses system/allocatable capacity for fit math while preserving rated total for labels. | | `crates/mesh-llm-ui/src/features/configuration/components/VRAMBar.tsx` | config math props | user-facing and internal UI | Displays total/reserved/free lanes; sizing is driven by system capacity. | From 247a4a5dbc6df52e000860c6a7b76b6032e6fcd1 Mon Sep 17 00:00:00 2001 From: jy Date: Thu, 10 Sep 2026 15:17:06 +1000 Subject: [PATCH 18/41] fix(skippy-cache): rework L2 tier onto shared immutable segments (#1651) Review corrections on the first #1651 slice (PR #1749): - Store immutable Arc segment handles keyed by content digest plus a prefix-to-segment layout, so a longer prefix shares the prefix bytes of the shorter entry instead of duplicating them. Turn growth no longer trends toward quadratic RAM; the budget is charged with distinct segment bytes only. - Make the integrity claim true: admit() verifies the concatenated L3 wire digest (segment_digest) exactly once at admission and refuses mismatched bytes (DigestMismatch), so Direct origin can no longer admit arbitrary bytes under a valid-looking digest. Timed reads are digest-keyed verified-handle lookups, not digest checks. - peek() is now side-effect free: it never touches LRU recency. Recency moves only on a successful get(). - Bench harness rejects zero pairs/tokens/bytes-per-token and uses checked arithmetic for payload length and MiB conversion; the byte-equality gate stays outside the timer; the L2 metric is renamed to lookup/assembly-handle time with one-time admission hashing reported separately. --- crates/skippy-bench/src/l2_tier.rs | 80 +- crates/skippy-cache/src/l2/mod.rs | 1186 +++++++++++++++++----- crates/skippy-cache/src/payload/bytes.rs | 41 +- 3 files changed, 1040 insertions(+), 267 deletions(-) diff --git a/crates/skippy-bench/src/l2_tier.rs b/crates/skippy-bench/src/l2_tier.rs index 65b796b2f5..1ba2f9009b 100644 --- a/crates/skippy-bench/src/l2_tier.rs +++ b/crates/skippy-bench/src/l2_tier.rs @@ -1,13 +1,18 @@ -//! `l2-tier` benchmark: cold L3 fill versus warm L2 fill on identical packed -//! entries (#1651). +//! `l2-tier` benchmark: cold L3 fill versus warm L2 lookup on identical +//! packed entries (#1651). //! //! Builds a temporary L3 store, spills a synthetic multi-turn prompt at a //! recorded prefix length, then measures two restore paths in-process: //! //! - **L3 cold fill**: `L3Tier::fill_longest` — index probe + segment //! assembly + digest verification from disk. -//! - **L2 warm fill**: `L2Tier::peek` + `get` — the entry was captured from -//! an identical L3 fill, so the hit is a handle clone plus digest check. +//! - **L2 warm lookup**: `L2Tier::get` + `to_payload` — the entry was +//! admitted from an identical verified L3 fill, so the lookup is a +//! digest-keyed handle assembly (no re-hash: admission verified the +//! wire once; segments are immutable afterward). +//! +//! Admission hashing (the one-time wire BLAKE3) is measured separately +//! and reported as its own metric, never inside the timed lookup. //! //! Both paths produce payloads with identical bytes; the harness asserts //! that before timing so a correctness regression cannot hide behind a @@ -28,13 +33,28 @@ fn percentile(samples_ns: &mut [u128], pct: f64) -> f64 { } pub fn l2_tier(args: L2TierArgs) -> Result<()> { + // Validation: reject degenerate configurations up front instead of + // dividing by zero or allocating nothing below. + if args.pairs == 0 { + anyhow::bail!("--pairs must be at least 1"); + } + if args.tokens == 0 { + anyhow::bail!("--tokens must be at least 1"); + } + if args.kv_bytes_per_token == 0 { + anyhow::bail!("--kv-bytes-per-token must be at least 1"); + } + let namespace = "bench-namespace"; let state_identity = args.model_identity.clone(); let token_ids: Vec = (0..args.tokens).map(|i| (i % 128_000) as i32).collect(); // Deterministic synthetic KV payload: content matters only for digests, // size matters for timing. - let payload_len = args.tokens * args.kv_bytes_per_token; + let payload_len = args + .tokens + .checked_mul(args.kv_bytes_per_token) + .context("--tokens * --kv-bytes-per-token overflows")?; let payload_bytes: Vec = (0..payload_len).map(|i| (i % 251) as u8).collect(); let payload = ExactStatePayload::full_state(payload_bytes); @@ -64,25 +84,36 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { let l2_budget_bytes = args .l2_budget_mib - .map(|mib| mib * 1024 * 1024) + .map(|mib| { + mib.checked_mul(1024 * 1024) + .context("--l2-budget-mib overflows") + }) + .transpose()? .unwrap_or(payload_len as u64 * 4); let l2 = L2Tier::new(l2_budget_bytes); - // Warmup: one of each path, then capture the L3 fill into L2 so the - // warm path is genuinely populated from L3, not inserted by fiat. + let cache_key = l2_cache_key(&args.model_identity, &state_identity, namespace, &token_ids); + + // Warmup: one L3 fill, then admit it into L2 from the verified wire. + // The wire check inside `admit` is the one-time admission hash; it is + // timed separately below. let warm_fill = tier .fill_longest(namespace, &token_ids, 8) .context("bench warmup L3 fill failed")? .context("bench warmup L3 fill missed")?; - let cache_key = l2_cache_key(&args.model_identity, &state_identity, namespace, &token_ids); - l2.insert( + let (warm_wire, _) = warm_fill.payload.full_state_bytes_timed().context("wire")?; + let admission_started = Instant::now(); + l2.admit( cache_key.clone(), warm_fill.token_count, payload_digest.clone(), - ExactStatePayloadMirror::capture(&warm_fill.payload), + &warm_wire, + ExactStatePayloadMirror::from_manifest(&manifest) + .map_err(|refusal| anyhow::anyhow!(refusal.reason()))?, L2Origin::FromL3, ) - .map_err(|refusal| anyhow::anyhow!("bench warmup L2 insert refused: {}", refusal.reason()))?; + .map_err(|refusal| anyhow::anyhow!("bench warmup L2 admit refused: {}", refusal.reason()))?; + let admission_hash_ns = admission_started.elapsed().as_nanos(); let mut l3_samples: Vec = Vec::with_capacity(args.pairs); let mut l2_samples: Vec = Vec::with_capacity(args.pairs); @@ -99,25 +130,23 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { let l3_ns = start.elapsed().as_nanos(); let start = Instant::now(); - let hit = l2 - .peek(&cache_key) - .filter(|peek| peek.payload_digest == payload_digest) - .map(|_| ()) - .and_then(|()| l2.get(&cache_key, &payload_digest)); + let hit = l2.get(&cache_key); + let l2_payload = hit.as_ref().map(|hit| hit.to_payload()); let l2_ns = start.elapsed().as_nanos(); - let hit = hit.context("bench L2 get missed after peek")?; - // Correctness gate: L2 must return byte-identical state to the L3 - // fill, or the speedup is meaningless. + // Correctness gate, outside the timer: L2 must return + // byte-identical state to the L3 fill, or the speedup is + // meaningless. + let hit = hit.context("bench L2 lookup missed")?; + let l2_payload = l2_payload.context("bench L2 payload missing")?; + anyhow::ensure!(hit.token_count == fill.token_count); + anyhow::ensure!(hit.payload_digest == payload_digest); let (l3_bytes, _) = fill.payload.full_state_bytes_timed().context("l3 bytes")?; - let l2_payload = hit.payload.to_payload(); let (l2_bytes, _) = l2_payload.full_state_bytes_timed().context("l2 bytes")?; anyhow::ensure!( l3_bytes == l2_bytes, "pair {pair}: L2 payload diverged from L3 fill" ); - anyhow::ensure!(hit.token_count == fill.token_count); - let _ = (l3_bytes, l2_bytes); l3_samples.push(l3_ns); l2_samples.push(l2_ns); @@ -137,16 +166,19 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { "p50": percentile(&mut l3_sorted, 50.0), "p99": percentile(&mut l3_sorted, 99.0), }, - "l2_fill_ns": { + "l2_lookup_assembly_handle_ns": { "p50": percentile(&mut l2_sorted, 50.0), "p99": percentile(&mut l2_sorted, 99.0), }, + "l2_admission_hash_ns_one_time": admission_hash_ns, "speedup_p50": percentile(&mut l3_sorted, 50.0) / percentile(&mut l2_sorted, 50.0).max(1.0), "l2_stats": { "hits": stats.hits, "misses": stats.misses, "evictions": stats.evictions, "bytes": stats.bytes, + "segments": stats.segments, + "shared_bytes_admitted": stats.shared_bytes_admitted, }, "l3_prefix_key": l3_prefix_key(namespace, &token_ids), }); diff --git a/crates/skippy-cache/src/l2/mod.rs b/crates/skippy-cache/src/l2/mod.rs index 26bb21d060..bb20e1eee1 100644 --- a/crates/skippy-cache/src/l2/mod.rs +++ b/crates/skippy-cache/src/l2/mod.rs @@ -2,8 +2,9 @@ //! //! The radix cache (L1) holds resident payloads; the L3 tier holds the same //! state durably on disk as content-addressed packed segments. This module -//! adds the missing middle tier: a bounded host-RAM cache of *assembled L3 -//! entries*, keyed and identified exactly like the L3 entries they mirror. +//! adds the missing middle tier: a bounded host-RAM cache of *immutable +//! packed segments*, keyed and identified exactly like the L3 entries they +//! mirror. //! //! Contract (mirrors `crate::tier::L3Tier`): //! @@ -14,173 +15,361 @@ //! `(namespace, token path)` coordinates L3 uses — //! [`crate::tier::l3_prefix_key`] / [`crate::tier::l3_namespace_key`] — so //! an L2 hit is interchangeable with the L3 entry it cached. -//! - **Integrity**: stored payloads keep their whole-payload BLAKE3 digest -//! (the manifest key). Reads verify the digest; a mismatch is recorded, -//! dropped, and reported as absence — never returned as state. -//! - **Bounded**: the byte budget is enforced on every insert by evicting in -//! deterministic LRU order. A payload larger than the budget is refused. -//! - **Zero-copy reads**: `get` clones the handle, not the bytes; callers -//! receive `CacheBytes` mirrors of the stored buffers. +//! - **Segment sharing**: an entry stores immutable `Arc>` segment +//! handles keyed by their content digests plus a layout that maps the +//! entry's L3 manifest segment list onto those handles. A longer prefix +//! that extends a shorter one references the same segment handles, so +//! turn growth shares prefix bytes instead of duplicating them — L2 RAM +//! tracks *distinct segment* bytes, not per-prefix assembled bytes. +//! - **Integrity**: the whole concatenated L3 wire digest (the manifest key) +//! is verified exactly once, at admission, against the payload being +//! admitted. After admission the segment bytes are immutable, so every +//! later read is a digest lookup plus handle assembly — no re-hash. An +//! admission-time mismatch refuses the insert; L2 never holds bytes it +//! did not verify. +//! - **Bounded**: the byte budget is charged with each entry's *distinct* +//! segment bytes (bytes not already held by an in-flight insert) and +//! enforced by evicting in deterministic LRU order. A payload whose +//! distinct bytes exceed the whole budget is refused. A segment shared +//! with an already-admitted entry is shared for accounting too: only the +//! first admission pays for it. +//! - **Zero-copy reads**: `get` clones handles, not bytes; the returned +//! `CacheBytes` is a block-backed view over the shared segment storages, +//! contiguous in the single-segment case. //! //! This first slice is a standalone store with no wiring into the request //! path; the benchmark harness drives it directly. L2 promotion/demotion //! policy and server integration land in a later slice. -use std::collections::HashMap; -use std::sync::{ - Mutex, - atomic::{AtomicU64, Ordering}, +use std::{ + collections::HashMap, + ops::Range, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, }; use crate::payload::{CacheBytes, ExactStatePayloadKind}; +use crate::{HandoffManifest, segment_digest}; +#[cfg(test)] +use crate::{HandoffSegmentRef, MANIFEST_VERSION}; /// Where an entry came from, for telemetry and promotion policy later. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum L2Origin { - /// Copied out of an assembled L3 fill. + /// Admitted from an assembled L3 fill (verified wire). FromL3, - /// Inserted directly (tests, prefetch, or a future wire source). + /// Admitted from another verified wire source (tests, prefetch). Direct, } /// LRU eviction accounting for one removed entry. +/// +/// `freed_bytes` is what removal actually released: segments whose last +/// referencing entry left the tier. `retained_bytes` is shared-segment +/// bytes that stay because another entry still references them. #[derive(Debug, Clone, PartialEq, Eq)] pub struct L2Eviction { pub cache_key: String, - pub payload_bytes: u64, + pub freed_bytes: u64, + pub retained_bytes: u64, +} + +impl L2Eviction { + /// Bytes charged to the budget for this entry (what its removal freed). + pub fn payload_bytes(&self) -> u64 { + self.freed_bytes + } } /// Read path counters. One snapshot per `stats()` call. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct L2Stats { pub entries: u64, + /// Sum of entries' distinct segment charges — the live budget usage. pub bytes: u64, + /// Sum of entry payload lengths including cross-entry sharing; larger + /// than `bytes` exactly when entries share prefix segments. + pub logical_bytes: u64, + /// Distinct immutable segment handles currently held. + pub segments: u64, + /// Bytes held in the segment pool (== `bytes` when the pool is live). + pub segment_bytes: u64, + /// Distinct segment bytes a single admission did not have to copy + /// because an earlier admission already held them. + pub shared_bytes_admitted: u64, pub budget_bytes: u64, pub hits: u64, pub misses: u64, pub inserts: u64, pub evictions: u64, - pub digest_mismatches: u64, + /// Admissions refused because the payload digest did not match the + /// bytes (hash mismatch or malformed digest string). + pub admission_rejects: u64, pub refused_bytes: u64, } -/// What L2 actually stores: the payload bytes split the way -/// `ExactStatePayload` splits them, so a fill can be rebuilt cheaply. +/// One assembled L2 entry: which stored segment handles make up the wire, +/// in manifest order, plus the payload split so a fill can be rebuilt. +#[derive(Debug, Clone)] +pub struct L2Layout { + pub payload_kind: ExactStatePayloadKind, + pub total_bytes: u64, + pub kv_bytes: u64, + pub recurrent_bytes: u64, + /// `(segment digest, byte range within the assembled wire)` per + /// manifest segment, in manifest order. Ranges concatenate to + /// `0..total_bytes` exactly as the L3 manifest tiles them. + pub segments: Vec<(String, Range)>, +} + +/// The L2 mirror of an assembled L3 entry: verified segment handles plus +/// the layout needed to rebuild a serving payload without disk I/O. #[derive(Debug, Clone)] pub enum ExactStatePayloadMirror { - FullState { - bytes: CacheBytes, - }, - RecurrentOnly { - recurrent: CacheBytes, - }, - KvRecurrent { - kv: CacheBytes, - recurrent: CacheBytes, - }, + FullState { layout: L2Layout }, + RecurrentOnly { layout: L2Layout }, + KvRecurrent { layout: L2Layout }, } impl ExactStatePayloadMirror { - pub fn from_parts(kind: ExactStatePayloadKind, kv: CacheBytes, recurrent: CacheBytes) -> Self { - match kind { - ExactStatePayloadKind::FullState => Self::FullState { bytes: kv }, - ExactStatePayloadKind::RecurrentOnly => Self::RecurrentOnly { recurrent }, - ExactStatePayloadKind::KvRecurrent => Self::KvRecurrent { kv, recurrent }, + pub fn kind(&self) -> ExactStatePayloadKind { + match self { + Self::FullState { .. } => ExactStatePayloadKind::FullState, + Self::RecurrentOnly { .. } => ExactStatePayloadKind::RecurrentOnly, + Self::KvRecurrent { .. } => ExactStatePayloadKind::KvRecurrent, } } + /// Total wire length of the entry (the assembled payload length). pub fn byte_len(&self) -> u64 { match self { - Self::FullState { bytes } => bytes.len(), - Self::RecurrentOnly { recurrent } => recurrent.len(), - Self::KvRecurrent { kv, recurrent } => kv.len().saturating_add(recurrent.len()), + Self::FullState { layout } + | Self::RecurrentOnly { layout } + | Self::KvRecurrent { layout } => layout.total_bytes, } } - /// Capture a serving payload into a mirror. Any internal block - /// reconstruction is shared via `CacheBytes` handles, not copied. - pub fn capture(payload: &crate::payload::ExactStatePayload) -> Self { - match payload { - crate::payload::ExactStatePayload::FullState { bytes } => Self::FullState { - bytes: bytes.clone(), - }, - crate::payload::ExactStatePayload::RecurrentOnly { recurrent } => Self::RecurrentOnly { - recurrent: recurrent.clone(), - }, - crate::payload::ExactStatePayload::KvRecurrent { kv, recurrent } => Self::KvRecurrent { - kv: kv.clone(), - recurrent: recurrent.clone(), - }, + /// Build a mirror from a captured L3 manifest. Callers must verify the + /// payload wire against `manifest.payload_digest` — `admit` does this — + /// before the mirror is stored. + pub fn from_manifest(manifest: &HandoffManifest) -> Result { + let kind = match manifest.payload_kind.as_str() { + "full-state" => ExactStatePayloadKind::FullState, + "recurrent-only" => ExactStatePayloadKind::RecurrentOnly, + "kv-recurrent" => ExactStatePayloadKind::KvRecurrent, + other => { + return Err(L2InsertRefusal::UnknownPayloadKind(other.to_string())); + } + }; + let mut offset = 0u64; + let segments = + manifest + .segments + .iter() + .map(|segment| { + let start = offset; + offset = offset.checked_add(segment.bytes).ok_or( + L2InsertRefusal::MalformedManifest("segment tiling overflows".to_string()), + )?; + Ok((segment.digest.clone(), start..offset)) + }) + .collect::, _>>()?; + if offset != manifest.total_bytes { + return Err(L2InsertRefusal::MalformedManifest(format!( + "segments tile {offset} bytes but the manifest records {}", + manifest.total_bytes + ))); } + let layout = L2Layout { + payload_kind: kind, + total_bytes: manifest.total_bytes, + kv_bytes: manifest.kv_bytes, + recurrent_bytes: manifest.recurrent_bytes, + segments, + }; + Ok(match kind { + ExactStatePayloadKind::FullState => Self::FullState { layout }, + ExactStatePayloadKind::RecurrentOnly => Self::RecurrentOnly { layout }, + ExactStatePayloadKind::KvRecurrent => Self::KvRecurrent { layout }, + }) } - /// Rebuild a serving payload from the mirror. Cheap: `CacheBytes` is - /// `Arc`-backed, so this shares the stored buffers rather than copying. - pub fn to_payload(&self) -> crate::payload::ExactStatePayload { + fn layout(&self) -> &L2Layout { match self { - Self::FullState { bytes } => crate::payload::ExactStatePayload::FullState { - bytes: bytes.clone(), - }, - Self::RecurrentOnly { recurrent } => crate::payload::ExactStatePayload::RecurrentOnly { - recurrent: recurrent.clone(), - }, - Self::KvRecurrent { kv, recurrent } => crate::payload::ExactStatePayload::KvRecurrent { - kv: kv.clone(), - recurrent: recurrent.clone(), - }, + Self::FullState { layout } + | Self::RecurrentOnly { layout } + | Self::KvRecurrent { layout } => layout, } } + + /// Segment digests in wire order, deduplicated. + fn segment_digests(&self) -> Vec<&str> { + let mut seen = Vec::new(); + for (digest, _) in &self.layout().segments { + if !seen.contains(&digest.as_str()) { + seen.push(digest.as_str()); + } + } + seen + } } -/// Verified hit. +/// A hit handed to the caller: the entry's layout plus `Arc` clones of the +/// segment handles the layout references, keyed by digest. The tier stores +/// this directly on `L2Hit` so payload assembly needs no tier lock. #[derive(Debug, Clone)] pub struct L2Hit { pub payload: ExactStatePayloadMirror, pub token_count: u64, pub payload_digest: String, + /// Distinct segment handles referenced by the layout, keyed by digest. + pub(crate) segments: HashMap, +} + +impl L2Hit { + /// Rebuild a serving payload. Cheap in the common cases: the returned + /// `CacheBytes` is a block-backed view sharing the stored segment + /// storages (`Arc` clones, not byte copies); a single whole-storage + /// segment borrows it contiguously. Only a multi-segment read of + /// distinct storages materializes bytes, and only into the caller's + /// `Cow` on `as_cow`. + pub fn to_payload(&self) -> crate::payload::ExactStatePayload { + let layout = self.payload.layout(); + let wire = self.wire_view(0..layout.total_bytes); + match self.payload.kind() { + crate::payload::ExactStatePayloadKind::FullState => { + crate::payload::ExactStatePayload::FullState { bytes: wire } + } + crate::payload::ExactStatePayloadKind::RecurrentOnly => { + crate::payload::ExactStatePayload::RecurrentOnly { recurrent: wire } + } + crate::payload::ExactStatePayloadKind::KvRecurrent => { + // Split the wire at kv_bytes exactly like L3 load does: kv + // is the leading block-backed view, recurrent the tail. Both + // share the same storages; no bytes are copied here. + let kv_len = layout.kv_bytes.min(layout.total_bytes); + let kv = self.wire_view(0..kv_len); + let recurrent = self.wire_view(kv_len..layout.total_bytes); + crate::payload::ExactStatePayload::KvRecurrent { kv, recurrent } + } + } + } + + /// Block-backed `CacheBytes` over `range` of the assembled wire, in + /// wire order. Blocks outside `range` are skipped; edge blocks are + /// narrowed to the overlap. Byte-identical views share the same + /// segment storages; nothing is copied. + pub(crate) fn wire_view(&self, range: Range) -> CacheBytes { + let layout = self.payload.layout(); + let start = range.start.min(layout.total_bytes); + let end = range.end.min(layout.total_bytes); + let blocks = layout + .segments + .iter() + .filter_map(|(digest, segment_range)| { + let block_start = segment_range.start.max(start); + let block_end = segment_range.end.min(end); + if block_start >= block_end { + return None; + } + let storage = self + .segments + .get(digest) + .map(|handle| Arc::clone(&handle.bytes)) + .unwrap_or_else(|| Arc::new(Vec::new())); + let len = storage.len() as u64; + let from = (block_start - segment_range.start).min(len); + let to = (block_end - segment_range.start).min(len); + Some((digest.clone(), storage, (from as usize)..(to as usize))) + }) + .collect::>(); + CacheBytes::from_shared_blocks(end.saturating_sub(start), blocks) + } } -/// Presence probe result. +/// Presence probe result. Probing never changes LRU recency. #[derive(Debug, Clone, PartialEq, Eq)] pub struct L2Peek { pub token_count: u64, pub payload_digest: String, + /// Total wire length including segments shared with other entries. pub payload_bytes: u64, + /// Distinct segment bytes charged to the budget for this entry. + pub distinct_bytes: u64, pub origin: L2Origin, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum L2InsertRefusal { EmptyPayload, - OverBudget { payload_bytes: u64 }, + OverBudget { + payload_bytes: u64, + }, MalformedDigest, + /// Admission hashing found the wire's BLAKE3 different from the digest + /// the payload claims (the L3 manifest key). + DigestMismatch { + expected: String, + actual: String, + }, + UnknownPayloadKind(String), + MalformedManifest(String), } impl L2InsertRefusal { - pub fn reason(&self) -> &'static str { + pub fn reason(&self) -> String { match self { - Self::EmptyPayload => "refusing to cache an empty exact-state payload", - Self::OverBudget { .. } => { - "payload exceeds the entire L2 budget; caching it would evict everything else" + Self::EmptyPayload => "refusing to cache an empty exact-state payload".to_string(), + Self::OverBudget { payload_bytes } => format!( + "payload of {payload_bytes} distinct bytes exceeds the entire L2 budget; \ + caching it would evict everything else" + ), + Self::MalformedDigest => { + "payload digest is not a 64-hex-character blake3 string".to_string() + } + Self::DigestMismatch { expected, actual } => format!( + "admission digest check failed: wire hashes to {actual} but the payload \ + claims {expected}" + ), + Self::UnknownPayloadKind(kind) => { + format!("manifest holds unknown payload kind {kind}") + } + Self::MalformedManifest(detail) => { + format!("malformed L3 manifest: {detail}") } - Self::MalformedDigest => "payload digest is not a 64-hex-character blake3 string", } } } +/// An immutable segment: content-addressed bytes shared by `Arc`. +#[derive(Debug, Clone)] +pub(crate) struct SegmentHandle { + pub bytes: Arc>, +} + #[derive(Debug)] struct L2Entry { payload: ExactStatePayloadMirror, token_count: u64, payload_digest: String, origin: L2Origin, - /// LRU clock, bumped on every hit/probe. + /// LRU clock, bumped on successful hits only (probes are side-effect + /// free). last_used: u64, + /// Distinct segment bytes charged against the budget. Shared segments + /// already held by other entries are not charged here. + charge_bytes: u64, + /// Total wire length including shared segments (telemetry). payload_bytes: u64, } #[derive(Default)] struct L2Inner { map: HashMap, + /// Content-addressed pool of immutable segments. + segments: HashMap, + /// Distinct segment bytes in the pool — the real RAM footprint. bytes: u64, clock: u64, } @@ -192,17 +381,25 @@ struct L2AtomicStats { misses: AtomicU64, inserts: AtomicU64, evictions: AtomicU64, - digest_mismatches: AtomicU64, + admission_rejects: AtomicU64, refused_bytes: AtomicU64, + shared_bytes_admitted: AtomicU64, } -/// Bounded host-RAM L2 of assembled exact-state payloads. +/// Bounded host-RAM L2 over immutable packed L3 segments. pub struct L2Tier { inner: Mutex, budget_bytes: u64, stats: L2AtomicStats, } +/// A digest string must be a BLAKE3 hex digest: `blake3:`-prefixed (as L3 +/// digests are) or bare 64 hex characters. +fn is_valid_digest(digest: &str) -> bool { + let hex = digest.strip_prefix("blake3:").unwrap_or(digest); + hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit()) +} + impl L2Tier { pub fn new(budget_bytes: u64) -> Self { Self { @@ -216,126 +413,201 @@ impl L2Tier { self.budget_bytes } - /// Insert an assembled entry. `payload_digest` is the whole-payload - /// BLAKE3 (the L3 manifest key) and is verified on read. Returns the - /// evictions the insert caused, so callers and tests can assert policy. - pub fn insert( + /// Admit an assembled entry. + /// + /// `wire` is the payload's concatenated L3 wire — the exact bytes whose + /// BLAKE3 is the manifest key. Admission verifies + /// `segment_digest(&wire) == payload_digest` exactly once and refuses + /// the insert on mismatch: L2 never holds bytes it did not verify. + /// After admission the segment bytes are immutable, so reads are a + /// digest lookup plus handle assembly — no re-hash. + /// + /// Returns the evictions the admission caused, so callers and tests can + /// assert policy. The budget is charged with the entry's *distinct* + /// segment bytes: segments already held by another entry are shared, + /// not duplicated, and only the first admission pays for them. + pub fn admit( &self, cache_key: String, token_count: u64, payload_digest: String, - payload: ExactStatePayloadMirror, + wire: &[u8], + mirror: ExactStatePayloadMirror, origin: L2Origin, ) -> Result, L2InsertRefusal> { - let payload_bytes = payload.byte_len(); + if !is_valid_digest(&payload_digest) { + self.stats.admission_rejects.fetch_add(1, Ordering::Relaxed); + return Err(L2InsertRefusal::MalformedDigest); + } + // The one integrity check: the wire must hash to the claimed + // manifest-key digest. + let actual = segment_digest(wire); + if actual != payload_digest { + self.stats.admission_rejects.fetch_add(1, Ordering::Relaxed); + return Err(L2InsertRefusal::DigestMismatch { + expected: payload_digest, + actual, + }); + } + let payload_bytes = mirror.byte_len(); if payload_bytes == 0 { // Mirrors L3: an empty payload cannot represent state. return Err(L2InsertRefusal::EmptyPayload); } - if payload_bytes > self.budget_bytes { + if payload_bytes != wire.len() as u64 { + return Err(L2InsertRefusal::MalformedManifest(format!( + "mirror claims {payload_bytes} payload bytes but the verified wire holds {}", + wire.len() + ))); + } + let mut inner = self.inner.lock().expect("L2 map lock poisoned"); + // Distinct-byte charge: segments the pool already holds (shared + // prefix with another entry) cost nothing new, provided the pool's + // copy still covers the segment's full length. Anything else is cut + // out of the verified wire. + let mut new_segments: Vec<(String, SegmentHandle)> = Vec::new(); + let mut new_bytes = 0u64; + let mut shared_bytes = 0u64; + for (digest, range) in &mirror.layout().segments { + let expected_len = (range.end.saturating_sub(range.start)) as usize; + if let Some(handle) = inner.segments.get(digest) { + if handle.bytes.len() == expected_len { + shared_bytes += expected_len as u64; + continue; + } + // Pool copy disagrees with the verified wire: replace it. + let stale = inner.segments.remove(digest); + if let Some(handle) = stale { + inner.bytes = inner.bytes.saturating_sub(handle.bytes.len() as u64); + } + } + if let Some(position) = new_segments.iter().position(|(d, _)| d == digest) { + // Within-admission duplicate: keep the first copy. + let existing = &new_segments[position].1; + if existing.bytes.len() == expected_len { + shared_bytes += expected_len as u64; + continue; + } + new_bytes = new_bytes.saturating_sub(existing.bytes.len() as u64); + new_segments.remove(position); + } + let start = (range.start as usize).min(wire.len()); + let end = (range.end as usize).min(wire.len()); + let bytes = Arc::new(wire[start..end].to_vec()); + new_bytes = new_bytes.saturating_add(bytes.len() as u64); + new_segments.push((digest.to_string(), SegmentHandle { bytes })); + } + if new_bytes > self.budget_bytes { self.stats .refused_bytes - .fetch_add(payload_bytes, Ordering::Relaxed); - return Err(L2InsertRefusal::OverBudget { payload_bytes }); - } - let digest_is_hex = - payload_digest.len() == 64 && payload_digest.bytes().all(|b| b.is_ascii_hexdigit()); - if !digest_is_hex { - return Err(L2InsertRefusal::MalformedDigest); + .fetch_add(new_bytes, Ordering::Relaxed); + return Err(L2InsertRefusal::OverBudget { + payload_bytes: new_bytes, + }); } - let mut inner = self.inner.lock().expect("L2 map lock poisoned"); - // One entry per cache key: a re-insert at the same coordinates is a + // One entry per cache key: a re-admit at the same coordinates is a // replacement (fresher state for the same prefix), not a duplicate. if let Some(existing) = inner.map.remove(&cache_key) { - inner.bytes = inner.bytes.saturating_sub(existing.payload_bytes); + self.release_entry_segments(&mut inner, &existing); } + // Evict to make room BEFORE the new segments land: the projected + // footprint is the live pool plus this admission's distinct bytes. + let headroom = self.budget_bytes.saturating_sub(new_bytes); + let evictions = self.evict_to_limit(&mut inner, headroom, &cache_key); inner.clock = inner.clock.wrapping_add(1); let last_used = inner.clock; + for (digest, handle) in new_segments { + inner.bytes = inner.bytes.saturating_add(handle.bytes.len() as u64); + inner.segments.insert(digest, handle); + } + self.stats + .shared_bytes_admitted + .fetch_add(shared_bytes, Ordering::Relaxed); inner.map.insert( cache_key.clone(), L2Entry { - payload, + payload: mirror, token_count, payload_digest, origin, last_used, + charge_bytes: new_bytes, payload_bytes, }, ); - inner.bytes = inner.bytes.saturating_add(payload_bytes); self.stats.inserts.fetch_add(1, Ordering::Relaxed); - let evictions = self.evict_to_budget(&mut inner, &cache_key); Ok(evictions) } - /// A hit records recency and returns a clone of the stored mirror - /// (handle clones, not byte copies). A digest mismatch drops the entry - /// and counts as a miss: L2 must never serve state it cannot verify. - pub fn get(&self, cache_key: &str, expected_digest: &str) -> Option { + /// A verified hit records recency and returns the entry's layout with + /// `Arc` clones of its segment handles (no byte copies). Digests are + /// not re-hashed: admission verified the wire, and segments are + /// immutable afterward. + pub fn get(&self, cache_key: &str) -> Option { let mut inner = self.inner.lock().expect("L2 map lock poisoned"); - if inner - .map - .get(cache_key) - .is_none_or(|entry| entry.payload_digest != expected_digest) - { - // Digest mismatch drops the unverifiable entry; plain absence - // falls through as a recorded miss. - if let Some(removed) = inner.map.remove(cache_key) { - debug_assert!(removed.payload_digest != expected_digest); - inner.bytes = inner.bytes.saturating_sub(removed.payload_bytes); - self.stats.digest_mismatches.fetch_add(1, Ordering::Relaxed); - } - self.stats.misses.fetch_add(1, Ordering::Relaxed); - return None; - } - inner.clock = inner.clock.wrapping_add(1); - let now = inner.clock; - let entry = inner - .map - .get_mut(cache_key) - .expect("presence checked above"); + let now = { + inner.clock = inner.clock.wrapping_add(1); + inner.clock + }; + let entry = inner.map.get_mut(cache_key)?; entry.last_used = now; + let payload = entry.payload.clone(); + let token_count = entry.token_count; + let payload_digest = entry.payload_digest.clone(); self.stats.hits.fetch_add(1, Ordering::Relaxed); + let mut segments = HashMap::with_capacity(payload.segment_digests().len()); + for digest in payload.segment_digests() { + if let Some(handle) = inner.segments.get(digest) { + segments.insert(digest.to_string(), handle.clone()); + } + } Some(L2Hit { - payload: entry.payload.clone(), - token_count: entry.token_count, - payload_digest: entry.payload_digest.clone(), + payload, + token_count, + payload_digest, + segments, }) } - /// Presence probe without byte or digest work — the L2 equivalent of an - /// L3 index probe. The caller still `get`s with the expected digest - /// before serving state. + /// Presence probe: side-effect free. It does not touch LRU recency — + /// prefix probing must not make entries hot — and returns only + /// metadata. Recency is updated by `get` after a successful verified + /// hit. pub fn peek(&self, cache_key: &str) -> Option { - let mut inner = self.inner.lock().expect("L2 map lock poisoned"); - inner.clock += 1; - let now = inner.clock; - let entry = inner.map.get_mut(cache_key)?; - entry.last_used = now; - let peek = L2Peek { + let inner = self.inner.lock().expect("L2 map lock poisoned"); + let entry = inner.map.get(cache_key)?; + Some(L2Peek { token_count: entry.token_count, payload_digest: entry.payload_digest.clone(), payload_bytes: entry.payload_bytes, + distinct_bytes: entry.charge_bytes, origin: entry.origin, - }; - Some(peek) + }) } pub fn remove(&self, cache_key: &str) -> Option { let mut inner = self.inner.lock().expect("L2 map poisoned"); let removed = inner.map.remove(cache_key)?; - inner.bytes = inner.bytes.saturating_sub(removed.payload_bytes); + let before = inner.bytes; + self.release_entry_segments(&mut inner, &removed); + let freed = before.saturating_sub(inner.bytes); Some(L2Eviction { cache_key: cache_key.to_string(), - payload_bytes: removed.payload_bytes, + freed_bytes: freed, + retained_bytes: removed + .payload + .byte_len() + .saturating_sub(freed) + .min(removed.payload_bytes), }) } - /// Drop everything; returns the bytes released. + /// Drop everything; returns the distinct bytes released. pub fn clear(&self) -> u64 { let mut inner = self.inner.lock().expect("L2 map lock poisoned"); let bytes = inner.bytes; inner.map.clear(); + inner.segments.clear(); inner.bytes = 0; bytes } @@ -351,22 +623,59 @@ impl L2Tier { /// Point-in-time snapshot combining atomics with the locked totals. pub fn stats(&self) -> L2Stats { let inner = self.inner.lock().expect("L2 map lock poisoned"); + let logical_bytes = inner + .map + .values() + .map(|entry| entry.payload_bytes) + .sum::(); L2Stats { entries: inner.map.len() as u64, bytes: inner.bytes, + logical_bytes, + segments: inner.segments.len() as u64, + segment_bytes: inner.bytes, + shared_bytes_admitted: self.stats.shared_bytes_admitted.load(Ordering::Relaxed), budget_bytes: self.budget_bytes, hits: self.stats.hits.load(Ordering::Relaxed), misses: self.stats.misses.load(Ordering::Relaxed), inserts: self.stats.inserts.load(Ordering::Relaxed), evictions: self.stats.evictions.load(Ordering::Relaxed), - digest_mismatches: self.stats.digest_mismatches.load(Ordering::Relaxed), + admission_rejects: self.stats.admission_rejects.load(Ordering::Relaxed), refused_bytes: self.stats.refused_bytes.load(Ordering::Relaxed), } } - fn evict_to_budget(&self, inner: &mut L2Inner, protect_key: &str) -> Vec { + /// Drop an entry's exclusive segments from the pool, decrementing the + /// pool byte total. Shared segments stay: another entry still + /// references them. Zero-byte segments are dropped without accounting + /// (a pool without payload bytes must never charge the budget). + fn release_entry_segments(&self, inner: &mut L2Inner, entry: &L2Entry) { + for digest in entry.payload.segment_digests() { + let still_referenced = inner + .map + .values() + .any(|other| other.payload.segment_digests().contains(&digest)); + if still_referenced { + continue; + } + if let Some(handle) = inner.segments.remove(digest) { + let released = handle.bytes.len() as u64; + inner.bytes = inner.bytes.saturating_sub(released); + } + } + } + + /// Evict in deterministic LRU order until the pool fits `limit` bytes. + /// Shared segments are released only with their last referencing + /// entry; a victim that frees nothing is still counted as an eviction. + fn evict_to_limit( + &self, + inner: &mut L2Inner, + limit: u64, + protect_key: &str, + ) -> Vec { let mut evictions = Vec::new(); - while inner.bytes > self.budget_bytes { + while inner.bytes > limit { // Deterministic LRU: lowest last_used wins; ties break on cache // key so identical operation sequences produce identical // evictions. @@ -377,16 +686,18 @@ impl L2Tier { .min_by(|a, b| a.1.last_used.cmp(&b.1.last_used).then_with(|| a.0.cmp(b.0))) .map(|(key, _)| key.clone()); let Some(victim) = victim else { break }; - if let Some(removed) = inner.map.remove(&victim) { - inner.bytes = inner.bytes.saturating_sub(removed.payload_bytes); - self.stats.evictions.fetch_add(1, Ordering::Relaxed); - evictions.push(L2Eviction { - cache_key: victim, - payload_bytes: removed.payload_bytes, - }); - } else { + let Some(removed) = inner.map.remove(&victim) else { break; - } + }; + let before = inner.bytes; + self.release_entry_segments(inner, &removed); + let freed = before.saturating_sub(inner.bytes); + self.stats.evictions.fetch_add(1, Ordering::Relaxed); + evictions.push(L2Eviction { + cache_key: victim, + freed_bytes: freed, + retained_bytes: removed.payload_bytes.saturating_sub(freed), + }); } evictions } @@ -418,12 +729,64 @@ pub fn l2_cache_key( mod tests { use super::*; - const DIGEST_A: &str = "a616719e0a0d39dc0fe85cd2d0a5e0e2f5e6e10b6b5a0a6f1a1c1d3e5f708a90"; const DIGEST_B: &str = "b616719e0a0d39dc0fe85cd2d0a5e0e2f5e6e10b6b5a0a6f1a1c1d3e5f708a90"; - fn full_state_mirror(len: usize) -> ExactStatePayloadMirror { + thread_local! { + static FULL_WIRE: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + } + + /// Build a synthetic wire of `len` bytes and its true BLAKE3 digest. + fn wire(len: usize, fill: u8) -> (Vec, String) { + // Position-dependent bytes so equal-length slices are never equal + // content: segment digests stay distinct across the wire. + let bytes: Vec = (0..len) + .map(|i| (fill as usize + i) % 251) + .map(|v| v as u8) + .collect(); + let digest = segment_digest(&bytes); + FULL_WIRE.with(|cell| *cell.borrow_mut() = bytes.clone()); + (bytes, digest) + } + + /// Single-segment full-state mirror over `len` wire bytes. The segment + /// key is the content digest of the whole wire, as L3 would produce. + fn single_segment_mirror(w: &[u8]) -> ExactStatePayloadMirror { + let len = w.len() as u64; ExactStatePayloadMirror::FullState { - bytes: CacheBytes::inline(vec![7u8; len]), + layout: L2Layout { + payload_kind: ExactStatePayloadKind::FullState, + total_bytes: len, + kv_bytes: len, + recurrent_bytes: 0, + segments: vec![(segment_digest(w), 0..len)], + }, + } + } + + /// Manifest-shaped mirror: digest-keyed segments cut at every + /// `segment_len` boundary, matching how `from_manifest` tiles. + fn manifest_shaped_mirror(w: &[u8], segment_len: u64) -> ExactStatePayloadMirror { + // `w` is a suffix of the test's full wire: segment digests are + // keyed by offset in that full wire so entries sharing a prefix + // also share segment identity. + let full = FULL_WIRE.with(|cell| cell.borrow().clone()); + let len = w.len() as u64; + let mut segments = Vec::new(); + let mut offset = 0u64; + while offset < len { + let end = (offset + segment_len).min(len); + let digest = segment_digest(&full[offset as usize..end as usize]); + segments.push((digest, offset..end)); + offset = end; + } + ExactStatePayloadMirror::FullState { + layout: L2Layout { + payload_kind: ExactStatePayloadKind::FullState, + total_bytes: len, + kv_bytes: len, + recurrent_bytes: 0, + segments, + }, } } @@ -432,50 +795,262 @@ mod tests { } #[test] - fn insert_get_round_trip_verifies_digest() { + fn admit_get_round_trip_serves_wire_bytes() { let tier = L2Tier::new(1 << 20); let k = key("ns", &[1, 2, 3]); - tier.insert( + let (w, digest) = wire(64, 7); + tier.admit( k.clone(), 3, - DIGEST_A.to_string(), - full_state_mirror(64), + digest.clone(), + &w, + single_segment_mirror(&w), L2Origin::FromL3, ) - .expect("insert must fit"); - let hit = tier.get(&k, DIGEST_A).expect("digest match must hit"); + .expect("admission must fit"); + let hit = tier.get(&k).expect("admitted key must hit"); assert_eq!(hit.token_count, 3); assert_eq!(hit.payload.byte_len(), 64); - // Round-trips into a serving payload with the right byte count. - let payload = hit.payload.to_payload(); + assert_eq!(hit.payload_digest, digest); + // Round-trips into a serving payload with the right byte count and + // exactly the admitted wire bytes. + let payload = hit.to_payload(); assert_eq!(payload.byte_len(), 64); assert_eq!( payload.kind(), crate::payload::ExactStatePayloadKind::FullState ); + let (bytes, _) = payload.full_state_bytes_timed().expect("full state"); + assert_eq!(bytes.as_ref(), &w[..], "served bytes must equal the wire"); } #[test] - fn digest_mismatch_drops_entry_and_misses() { + fn admission_digest_mismatch_refuses_and_stores_nothing() { let tier = L2Tier::new(1 << 20); let k = key("ns", &[1, 2, 3]); - tier.insert( - k.clone(), + let (w, _) = wire(64, 7); + let err = tier + .admit( + k.clone(), + 3, + DIGEST_B.to_string(), + &w, + single_segment_mirror(&w), + L2Origin::Direct, + ) + .expect_err("a wire that does not hash to the claimed digest must be refused"); + assert!(matches!(err, L2InsertRefusal::DigestMismatch { .. })); + assert!(tier.peek(&k).is_none(), "refused bytes must not be stored"); + assert!(tier.get(&k).is_none()); + let stats = tier.stats(); + assert_eq!(stats.admission_rejects, 1); + assert_eq!(stats.entries, 0); + assert_eq!(stats.bytes, 0); + } + + #[test] + fn corrupted_wire_never_reaches_the_tier() { + // L2Origin::Direct with arbitrary bytes under a valid-looking + // digest is exactly the hole this closes: the digest check runs on + // the actual bytes. + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[9]); + let (mut w, digest) = wire(128, 3); + w[42] ^= 0xff; // one flipped bit + let err = tier + .admit( + k.clone(), + 1, + digest, + &w, + single_segment_mirror(&w), + L2Origin::Direct, + ) + .expect_err("corrupted wire must be refused at admission"); + assert!(matches!(err, L2InsertRefusal::DigestMismatch { .. })); + assert_eq!(tier.stats().admission_rejects, 1); + assert!(tier.is_empty()); + } + + #[test] + fn peek_is_side_effect_free_for_lru() { + let tier = L2Tier::new(160); + let k1 = key("ns", &[1]); + let k2 = key("ns", &[2]); + let (w1, d1) = wire(64, 1); + let (w2, d2) = wire(64, 2); + tier.admit( + k1.clone(), + 1, + d1, + &w1, + single_segment_mirror(&w1), + L2Origin::FromL3, + ) + .expect("k1 fits"); + tier.admit( + k2.clone(), + 1, + d2, + &w2, + single_segment_mirror(&w2), + L2Origin::FromL3, + ) + .expect("k2 fits"); + // Probe k1 many times: recency must not move. + for _ in 0..10 { + assert!(tier.peek(&k1).is_some()); + } + // Insert a third entry: k1 (never truly used) must still be the + // LRU victim, not k2. + let k3 = key("ns", &[3]); + let (w3, d3) = wire(64, 3); + let evictions = tier + .admit( + k3.clone(), + 1, + d3, + &w3, + single_segment_mirror(&w3), + L2Origin::FromL3, + ) + .expect("k3 fits after eviction"); + assert_eq!(evictions.len(), 1); + assert_eq!(evictions[0].cache_key, k1, "probed-but-unused k1 is LRU"); + assert!(tier.peek(&k2).is_some(), "untouched k2 survives"); + } + + #[test] + fn recency_moves_only_on_verified_hit() { + let tier = L2Tier::new(160); + let k1 = key("ns", &[1]); + let k2 = key("ns", &[2]); + let (w1, d1) = wire(64, 1); + let (w2, d2) = wire(64, 2); + tier.admit( + k1.clone(), + 1, + d1, + &w1, + single_segment_mirror(&w1), + L2Origin::FromL3, + ) + .expect("k1 fits"); + tier.admit( + k2.clone(), + 1, + d2, + &w2, + single_segment_mirror(&w2), + L2Origin::FromL3, + ) + .expect("k2 fits"); + // A real hit on k1 makes k2 the victim of the next insertion. + assert!(tier.get(&k1).is_some()); + let k3 = key("ns", &[3]); + let (w3, d3) = wire(64, 3); + let evictions = tier + .admit(k3, 1, d3, &w3, single_segment_mirror(&w3), L2Origin::FromL3) + .expect("k3 fits after eviction"); + assert_eq!(evictions.len(), 1); + assert_eq!(evictions[0].cache_key, k2, "k2 is now LRU"); + assert!(tier.peek(&k1).is_some(), "recently hit k1 survives"); + } + + #[test] + fn prefix_growth_shares_segment_bytes_instead_of_duplicating() { + // 16 KiB of four 4 KiB segments; the shorter prefix shares the + // first three segments with the longer one. + let segment_len = 4096u64; + let total = segment_len * 4; + let tier = L2Tier::new(total * 2); + let short = key("ns", &[1, 2, 3]); + let long = key("ns", &[1, 2, 3, 4, 5]); + let (w, digest) = wire(total as usize, 5); + let short_len = segment_len * 3; + tier.admit( + short.clone(), 3, - DIGEST_A.to_string(), - full_state_mirror(64), + segment_digest(&w[..short_len as usize]), + &w[..short_len as usize], + manifest_shaped_mirror(&w[..short_len as usize], segment_len), L2Origin::FromL3, ) - .expect("insert must fit"); - // Wrong expected digest: must be a miss, and the unverifiable entry - // must be gone afterward. - assert!(tier.get(&k, DIGEST_B).is_none()); - assert!(tier.peek(&k).is_none(), "mismatched entry must be dropped"); + .expect("short prefix admitted"); + + tier.admit( + long.clone(), + 5, + digest, + &w, + manifest_shaped_mirror(&w, segment_len), + L2Origin::FromL3, + ) + .expect("long prefix admitted"); + let stats = tier.stats(); - assert_eq!(stats.digest_mismatches, 1); - assert_eq!(stats.misses, 1); - assert_eq!(stats.entries, 0); - assert_eq!(stats.bytes, 0); + // The long entry pays only for its one new (4th) segment. + assert_eq!( + stats.bytes, total, + "pool must hold distinct segment bytes once: got {}", + stats.bytes + ); + assert_eq!( + stats.logical_bytes, + total + short_len, + "logical bytes count both entries' full wires" + ); + assert_eq!(stats.segments, 4, "four distinct segments, not seven"); + assert_eq!(stats.shared_bytes_admitted, short_len); + // Both entries serve their own wire slices. + let hit = tier.get(&long).expect("long hit"); + let payload = hit.to_payload(); + let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!(bytes.as_ref(), &w[..]); + let hit_short = tier.get(&short).expect("short hit"); + let payload_short = hit_short.to_payload(); + let (bytes_short, _) = payload_short.full_state_bytes_timed().expect("bytes"); + assert_eq!(bytes_short.as_ref(), &w[..short_len as usize]); + } + + #[test] + fn evicting_one_entry_keeps_shared_prefix_segments() { + let segment_len = 4096u64; + let total = segment_len * 4; + let tier = L2Tier::new(total * 2); + let short = key("ns", &[1]); + let long = key("ns", &[2]); + let (w, digest) = wire(total as usize, 6); + let short_len = segment_len * 3; + tier.admit( + short, + 3, + segment_digest(&w[..short_len as usize]), + &w[..short_len as usize], + manifest_shaped_mirror(&w[..short_len as usize], segment_len), + L2Origin::FromL3, + ) + .expect("short admitted"); + tier.admit( + long.clone(), + 5, + digest, + &w, + manifest_shaped_mirror(&w, segment_len), + L2Origin::FromL3, + ) + .expect("long admitted"); + // Removing the long entry frees only its exclusive tail segment. + let removed = tier.remove(&long).expect("long entry present"); + assert_eq!(removed.freed_bytes, segment_len); + assert_eq!( + removed.retained_bytes, short_len, + "shared prefix bytes are retained by the shorter entry" + ); + let stats = tier.stats(); + assert_eq!(stats.bytes, short_len); + assert_eq!(stats.segments, 3); + assert!(tier.get(&key("ns", &[1])).is_some(), "short entry intact"); } #[test] @@ -484,30 +1059,36 @@ mod tests { let k1 = key("ns", &[1]); let k2 = key("ns", &[2]); let k3 = key("ns", &[3]); - tier.insert( + let (w1, d1) = wire(100, 1); + let (w2, d2) = wire(100, 2); + let (w3, d3) = wire(100, 3); + tier.admit( k1.clone(), 1, - DIGEST_A.to_string(), - full_state_mirror(100), + d1, + &w1, + single_segment_mirror(&w1), L2Origin::FromL3, ) .expect("k1 fits"); - tier.insert( + tier.admit( k2.clone(), 1, - DIGEST_A.to_string(), - full_state_mirror(100), + d2, + &w2, + single_segment_mirror(&w2), L2Origin::FromL3, ) .expect("k2 fits"); // Touch k1 so k2 becomes the LRU victim. - assert!(tier.get(&k1, DIGEST_A).is_some()); + assert!(tier.get(&k1).is_some()); let evictions = tier - .insert( + .admit( k3.clone(), 1, - DIGEST_A.to_string(), - full_state_mirror(100), + d3, + &w3, + single_segment_mirror(&w3), L2Origin::FromL3, ) .expect("k3 fits after eviction"); @@ -517,33 +1098,38 @@ mod tests { "one entry must be evicted: {evictions:?}" ); assert_eq!(evictions[0].cache_key, k2, "LRU victim is k2"); - assert_eq!(evictions[0].payload_bytes, 100); + assert_eq!(evictions[0].freed_bytes, 100); + assert_eq!(evictions[0].retained_bytes, 0); assert!(tier.peek(&k1).is_some(), "recently used k1 survives"); - assert!(tier.peek(&k3).is_some(), "just-inserted k3 survives"); + assert!(tier.peek(&k3).is_some(), "just-admitted k3 survives"); assert!(tier.peek(&k2).is_none(), "k2 was evicted"); let stats = tier.stats(); assert_eq!(stats.evictions, 1); - assert_eq!(stats.bytes, 200, "bytes must track entries exactly"); + assert_eq!(stats.bytes, 200, "pool bytes must track survivors exactly"); } #[test] - fn oversized_payload_is_refused_without_evicting() { + fn oversized_distinct_bytes_are_refused_without_evicting() { let tier = L2Tier::new(128); let k1 = key("ns", &[1]); - tier.insert( + let (w1, d1) = wire(64, 1); + tier.admit( k1.clone(), 1, - DIGEST_A.to_string(), - full_state_mirror(64), + d1, + &w1, + single_segment_mirror(&w1), L2Origin::FromL3, ) .expect("fits"); + let (w2, d2) = wire(129, 2); let err = tier - .insert( + .admit( key("ns", &[2]), 1, - DIGEST_A.to_string(), - full_state_mirror(129), + d2, + &w2, + single_segment_mirror(&w2), L2Origin::FromL3, ) .expect_err("over-budget payload must be refused"); @@ -555,12 +1141,14 @@ mod tests { #[test] fn empty_payload_is_refused_like_l3() { let tier = L2Tier::new(1 << 20); + let empty_digest = segment_digest(&[]); let err = tier - .insert( + .admit( key("ns", &[1]), 1, - DIGEST_A.to_string(), - full_state_mirror(0), + empty_digest, + &[], + single_segment_mirror(&[]), L2Origin::FromL3, ) .expect_err("empty payloads must be refused"); @@ -569,14 +1157,16 @@ mod tests { } #[test] - fn malformed_digest_is_refused() { + fn malformed_digest_is_refused_before_any_hashing() { let tier = L2Tier::new(1 << 20); + let (w, _) = wire(16, 4); let err = tier - .insert( + .admit( key("ns", &[1]), 1, "not-a-digest".to_string(), - full_state_mirror(16), + &w, + single_segment_mirror(&w), L2Origin::FromL3, ) .expect_err("malformed digest must be refused"); @@ -584,32 +1174,43 @@ mod tests { } #[test] - fn reinsert_replaces_and_keeps_accounting_exact() { + fn readmit_replaces_and_keeps_accounting_exact() { let tier = L2Tier::new(1 << 20); let k = key("ns", &[9]); - tier.insert( + let (w1, d1) = wire(100, 1); + let (w2, d2) = wire(40, 2); + tier.admit( k.clone(), 3, - DIGEST_A.to_string(), - full_state_mirror(100), + d1, + &w1, + single_segment_mirror(&w1), L2Origin::FromL3, ) - .expect("first insert"); + .expect("first admission"); let evictions = tier - .insert( + .admit( k.clone(), 3, - DIGEST_B.to_string(), - full_state_mirror(40), + d2.clone(), + &w2, + single_segment_mirror(&w2), L2Origin::FromL3, ) .expect("replacement"); assert!(evictions.is_empty()); assert_eq!(tier.len(), 1); - assert_eq!(tier.stats().bytes, 40, "replacement must release old bytes"); + assert_eq!( + tier.stats().bytes, + 40, + "replacement must release the old bytes" + ); // New digest is the one served now. - assert!(tier.get(&k, DIGEST_B).is_some()); - assert!(tier.get(&k, DIGEST_A).is_none()); + let hit = tier.get(&k).expect("replacement hit"); + assert_eq!(hit.payload_digest, d2); + let payload = hit.to_payload(); + let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!(bytes.as_ref(), &w2[..]); } #[test] @@ -627,44 +1228,140 @@ mod tests { } #[test] - fn capture_round_trips_every_payload_kind() { - let full = crate::payload::ExactStatePayload::full_state(vec![1; 32]); - let rec = crate::payload::ExactStatePayload::recurrent_only(vec![2; 16]); - let kvrec = crate::payload::ExactStatePayload::kv_recurrent(vec![3; 24], vec![4; 8]); - - for payload in [&full, &rec, &kvrec] { - let mirror = ExactStatePayloadMirror::capture(payload); - assert_eq!(mirror.byte_len(), payload.byte_len()); - let rebuilt = mirror.to_payload(); - assert_eq!(rebuilt.byte_len(), payload.byte_len()); - assert_eq!(rebuilt.kind(), payload.kind()); - } + fn mirror_round_trips_every_payload_kind_from_manifest() { + // kv-recurrent: kv 24 bytes then recurrent 8, cut into two segments. + let (kv_wire, _) = wire(24, 3); + let (rec_wire, _) = wire(8, 4); + let wire_bytes: Vec = [kv_wire, rec_wire].concat(); + let digest = segment_digest(&wire_bytes); + let manifest = HandoffManifest { + version: MANIFEST_VERSION, + model_identity: "blake3:model".to_string(), + state_identity: "blake3:state".to_string(), + payload_kind: "kv-recurrent".to_string(), + total_bytes: wire_bytes.len() as u64, + payload_digest: digest.clone(), + segments: vec![ + HandoffSegmentRef { + index: 0, + offset: 0, + bytes: 16, + digest: "blake3:seg-a".to_string(), + meta_json: None, + }, + HandoffSegmentRef { + index: 1, + offset: 16, + bytes: 16, + digest: "blake3:seg-b".to_string(), + meta_json: None, + }, + ], + kv_bytes: 24, + recurrent_bytes: 8, + kv_desc_json: None, + token_count: 4, + continuation_token: 0, + expected_tokens: Vec::new(), + }; + let mirror = ExactStatePayloadMirror::from_manifest(&manifest).expect("manifest parses"); + assert_eq!(mirror.byte_len(), 32); + assert_eq!(mirror.kind(), ExactStatePayloadKind::KvRecurrent); - // Byte-for-byte identity survives the mirror for kv-recurrent. - let mirror = ExactStatePayloadMirror::capture(&kvrec); - let rebuilt = mirror.to_payload(); - let original_kv = kvrec - .kv_bytes() - .expect("kv bytes") - .map(|cow| cow.into_owned()) - .unwrap_or_default(); - let rebuilt_kv = rebuilt + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[7]); + tier.admit(k.clone(), 4, digest, &wire_bytes, mirror, L2Origin::FromL3) + .expect("fits"); + let hit = tier.get(&k).expect("hit"); + let payload = hit.to_payload(); + assert_eq!(payload.byte_len(), 32); + let served_kv = payload .kv_bytes() .expect("kv bytes") - .map(|cow| cow.into_owned()) - .unwrap_or_default(); - assert_eq!(original_kv, rebuilt_kv); + .expect("kv-recurrent has kv") + .into_owned(); + let served_rec = payload + .recurrent_state_bytes() + .expect("recurrent bytes") + .into_owned(); + assert_eq!(served_kv, wire_bytes[..24], "kv slice must match the wire"); + assert_eq!(served_rec, wire_bytes[24..], "recurrent slice matches"); + } + + #[test] + fn from_manifest_rejects_unknown_kind_and_bad_tiling() { + let mut manifest = HandoffManifest { + version: MANIFEST_VERSION, + model_identity: "m".to_string(), + state_identity: "s".to_string(), + payload_kind: "blob".to_string(), + total_bytes: 10, + payload_digest: "blake3:aa".to_string(), + segments: Vec::new(), + kv_bytes: 10, + recurrent_bytes: 0, + kv_desc_json: None, + token_count: 1, + continuation_token: 0, + expected_tokens: Vec::new(), + }; + let err = ExactStatePayloadMirror::from_manifest(&manifest) + .expect_err("unknown kind must be refused"); + assert!(matches!(err, L2InsertRefusal::UnknownPayloadKind(_))); + + manifest.payload_kind = "full-state".to_string(); + manifest.segments = vec![HandoffSegmentRef { + index: 0, + offset: 0, + bytes: 7, + digest: "blake3:seg".to_string(), + meta_json: None, + }]; + let err = ExactStatePayloadMirror::from_manifest(&manifest) + .expect_err("tiling mismatch must be refused"); + assert!(matches!(err, L2InsertRefusal::MalformedManifest(_))); + } + + #[test] + fn multi_segment_reads_reassemble_exact_wire() { + // 6 KiB in 1 KiB segments: every read path crosses many blocks. + let segment_len = 1024u64; + let total = segment_len * 6; + let (w, digest) = wire(total as usize, 9); + let tier = L2Tier::new(total * 2); + let k = key("ns", &[1]); + tier.admit( + k.clone(), + 6, + digest, + &w, + manifest_shaped_mirror(&w, segment_len), + L2Origin::FromL3, + ) + .expect("fits"); + let hit = tier.get(&k).expect("hit"); + let payload = hit.to_payload(); + let (bytes, reconstruct) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!(bytes.as_ref(), &w[..]); + // Multiple distinct segment storages materialize on read; the + // reconstruction length must equal the payload either way. + assert_eq!( + reconstruct.reconstruct_bytes, total, + "multi-segment reads materialize the wire exactly once" + ); } #[test] fn clear_releases_everything_and_reports_bytes() { let tier = L2Tier::new(1 << 20); for i in 0..5i32 { - tier.insert( + let (w, d) = wire(64, i as u8 + 10); + tier.admit( key("ns", &[i]), 1, - DIGEST_A.to_string(), - full_state_mirror(64), + d, + &w, + single_segment_mirror(&w), L2Origin::FromL3, ) .expect("fits"); @@ -673,23 +1370,28 @@ mod tests { assert_eq!(released, 320); assert!(tier.is_empty()); assert_eq!(tier.stats().bytes, 0); + assert_eq!(tier.stats().segments, 0); } #[test] fn remove_is_exact() { let tier = L2Tier::new(1 << 20); let k = key("ns", &[4]); - tier.insert( + let (w, d) = wire(64, 8); + tier.admit( k.clone(), 1, - DIGEST_A.to_string(), - full_state_mirror(64), + d, + &w, + single_segment_mirror(&w), L2Origin::FromL3, ) .expect("fits"); let removed = tier.remove(&k).expect("present entry removes"); - assert_eq!(removed.payload_bytes, 64); + assert_eq!(removed.freed_bytes, 64); + assert_eq!(removed.retained_bytes, 0); assert!(tier.remove(&k).is_none(), "second remove is None"); assert_eq!(tier.stats().bytes, 0); + assert_eq!(tier.stats().segments, 0); } } diff --git a/crates/skippy-cache/src/payload/bytes.rs b/crates/skippy-cache/src/payload/bytes.rs index 063684bc11..a3201c1b32 100644 --- a/crates/skippy-cache/src/payload/bytes.rs +++ b/crates/skippy-cache/src/payload/bytes.rs @@ -25,7 +25,7 @@ pub(super) enum CacheBytesRepr { } #[derive(Debug, Clone)] -pub(super) struct CacheBlockRef { +pub(crate) struct CacheBlockRef { pub(super) hash: String, /// Shared indirection lets eviction materialize a surviving deduped block /// before releasing its former contiguous backing allocation. @@ -71,6 +71,45 @@ impl CacheBytes { } } + /// Crate-internal: build a block-backed view over shared immutable + /// storages without copying bytes. Each item is `(hash, storage, range)`; + /// `hash` is bookkeeping identity for the block (the L2 tier uses the + /// segment digest). When exactly one item covers its whole storage the + /// view borrows it contiguously; otherwise reads reconstruct in block + /// order. + pub(crate) fn from_shared_blocks( + len: u64, + blocks: impl IntoIterator>, Range)>, + ) -> Self { + let refs: Vec = blocks + .into_iter() + .map(|(hash, storage, range)| { + CacheBlockRef::new( + hash, + Arc::new(RwLock::new(CacheBlockBytes::new(storage, range))), + ) + }) + .collect(); + let contiguous = match refs.as_slice() { + [single] => { + let bytes = single + .bytes + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (bytes.range.start == 0 && bytes.range.end == bytes.storage.len()) + .then(|| Arc::clone(&bytes.storage)) + } + _ => None, + }; + Self { + len, + repr: CacheBytesRepr::Blocks { + blocks: refs.into(), + contiguous, + }, + } + } + pub(super) fn blocks( len: u64, blocks: Vec, From 608ec6c38e9dc11641719f27b004806e82be2c57 Mon Sep 17 00:00:00 2001 From: jy Date: Thu, 10 Sep 2026 15:50:42 +1000 Subject: [PATCH 19/41] fix(skippy-cache): make L2 segment admission atomic and fully validated (#1651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review corrections round 2 on PR #1749, all against 247a4a5db: - admit() is transactional over the segment pool: the incoming layout's handles are installed or pinned (protected set) before the replaced entry is released and before eviction runs. An identical-wire same-key re-admit can no longer delete its own shared handles, and an admission that shares its eviction victim's segments can no longer have those handles dropped mid-transaction. Retained shared bytes transfer to the new entry's budget charge. - Pool reuse trusts content, not digest text: every layout segment slice is BLAKE3-hashed against its exact wire range before any pool mutation (validate_layout), the layout must tile contiguously with kv+recurrent == total, and from_manifest now checks segment index and offset. Same-digest/different-bytes offers are refused (SegmentDigestMismatch / ConflictingSegment) unless they replace the same key; a referenced content-addressed handle is never replaced. - get() with a missing segment handle is a cold miss: the corrupt entry and its surviving handles are removed, LRU never moves, and the miss counter (previously never incremented) now moves on every miss, including absent keys. - Bench: both arms are timed through the same usable-bytes boundary (handle lookup reported separately as l2_handle_lookup_ns); the store root is bench-owned — pre-existing paths are refused instead of deleted, the created root is sentinel-marked, and cleanup refuses unmarked directories (regression test included). Validation on this head: cargo test -p skippy-cache 144 passed / 1 ignored; cargo test -p skippy-bench 85 passed; cargo clippy -p skippy-cache -p skippy-bench --all-targets -- -D warnings clean; cargo fmt clean. --- crates/skippy-bench/src/cli.rs | 6 +- crates/skippy-bench/src/l2_tier.rs | 147 +++++++- crates/skippy-cache/src/l2/mod.rs | 583 +++++++++++++++++++++++++---- 3 files changed, 654 insertions(+), 82 deletions(-) diff --git a/crates/skippy-bench/src/cli.rs b/crates/skippy-bench/src/cli.rs index 4a81dc30e5..9ebca354ba 100644 --- a/crates/skippy-bench/src/cli.rs +++ b/crates/skippy-bench/src/cli.rs @@ -49,8 +49,10 @@ pub enum CommandKind { #[derive(Parser)] pub struct L2TierArgs { - /// Working directory for the temporary L3 store. Created and removed by - /// the run unless `--keep-store` is set. + /// Working directory for the L3 store. Must not exist: the bench + /// creates, sentinel-marks, and (unless `--keep-store`) removes a + /// directory it owns, and refuses any pre-existing path instead of + /// deleting it. #[arg(long, default_value = "/tmp/skippy-l2-tier-bench")] pub store_root: PathBuf, /// Number of timed L3-cold / L2-warm matched pairs after warmup. diff --git a/crates/skippy-bench/src/l2_tier.rs b/crates/skippy-bench/src/l2_tier.rs index 1ba2f9009b..52e8f961c3 100644 --- a/crates/skippy-bench/src/l2_tier.rs +++ b/crates/skippy-bench/src/l2_tier.rs @@ -6,17 +6,27 @@ //! //! - **L3 cold fill**: `L3Tier::fill_longest` — index probe + segment //! assembly + digest verification from disk. -//! - **L2 warm lookup**: `L2Tier::get` + `to_payload` — the entry was -//! admitted from an identical verified L3 fill, so the lookup is a -//! digest-keyed handle assembly (no re-hash: admission verified the -//! wire once; segments are immutable afterward). +//! - **L2 warm lookup**: `L2Tier::get` + `to_payload` + materialization — +//! the entry was admitted from an identical verified L3 fill, so the +//! lookup is a digest-keyed handle assembly (no re-hash: admission +//! verified the wire once; segments are immutable afterward). +//! +//! Both arms are timed through the same boundary: the moment their bytes +//! are usable (`full_state_bytes_timed`). A multi-segment L2 entry still +//! materializes its wire on read, so stopping the L2 timer at handle +//! creation would understate the real cost; the equality gate compares the +//! materialized bytes of both arms before the pair is counted. The +//! handle-only lookup time is reported separately as +//! `l2_handle_lookup_ns`. //! //! Admission hashing (the one-time wire BLAKE3) is measured separately //! and reported as its own metric, never inside the timed lookup. //! -//! Both paths produce payloads with identical bytes; the harness asserts -//! that before timing so a correctness regression cannot hide behind a -//! speedup. Output goes to stdout as JSON lines. +//! The store root is owned by the run: it must not exist beforehand (the +//! bench refuses existing paths instead of deleting user data) and it is +//! marked with an ownership sentinel so cleanup never touches a directory +//! the bench did not create. +use std::path::{Path, PathBuf}; use std::time::Instant; use anyhow::{Context, Result}; @@ -26,6 +36,44 @@ use skippy_cache::{ ExactStatePayload, ExactStatePayloadMirror, L2Origin, L2Tier, l2_cache_key, l3_prefix_key, }; +/// Marker file proving the bench created the store root itself; cleanup +/// refuses to `remove_dir_all` a directory without it. +const OWNERSHIP_SENTINEL: &str = ".skippy-l2-tier-bench-owned"; + +/// Create a fresh, bench-owned store root. Existing paths are refused — +/// the bench must never delete a user-supplied directory it did not +/// create. +fn prepare_store_root(requested: &Path) -> Result { + if requested.symlink_metadata().is_ok() { + anyhow::bail!( + "refusing to use store root {}: the path already exists; the bench only runs \ + in a root it created itself", + requested.display() + ); + } + std::fs::create_dir_all(requested) + .with_context(|| format!("failed to create bench store root {}", requested.display()))?; + std::fs::write( + requested.join(OWNERSHIP_SENTINEL), + b"skippy-bench l2-tier store\n", + ) + .with_context(|| format!("failed to mark {} as bench-owned", requested.display()))?; + Ok(requested.to_path_buf()) +} + +/// Remove a bench-owned store root. Refuses paths without the ownership +/// sentinel so `remove_dir_all` can never hit arbitrary input. +fn remove_owned_store_root(root: &Path) -> Result<()> { + if !root.join(OWNERSHIP_SENTINEL).is_file() { + anyhow::bail!( + "refusing to remove store root {}: missing bench ownership sentinel", + root.display() + ); + } + std::fs::remove_dir_all(root) + .with_context(|| format!("failed to remove bench store root {}", root.display())) +} + fn percentile(samples_ns: &mut [u128], pct: f64) -> f64 { samples_ns.sort_unstable(); let index = ((pct / 100.0) * (samples_ns.len() as f64 - 1.0)).round() as usize; @@ -45,6 +93,10 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { anyhow::bail!("--kv-bytes-per-token must be at least 1"); } + // Owned store root: refuse existing paths rather than deleting them, + // and mark the created directory so cleanup stays bounded to it. + let store_root = prepare_store_root(&args.store_root)?; + let namespace = "bench-namespace"; let state_identity = args.model_identity.clone(); let token_ids: Vec = (0..args.tokens).map(|i| (i % 128_000) as i32).collect(); @@ -58,9 +110,8 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { let payload_bytes: Vec = (0..payload_len).map(|i| (i % 251) as u8).collect(); let payload = ExactStatePayload::full_state(payload_bytes); - let _ = std::fs::remove_dir_all(&args.store_root); let tier = skippy_cache::L3Tier::open( - args.store_root.clone(), + store_root.clone(), (payload_len as u64) * 8, state_identity.clone(), 64 * 1024, @@ -117,6 +168,7 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { let mut l3_samples: Vec = Vec::with_capacity(args.pairs); let mut l2_samples: Vec = Vec::with_capacity(args.pairs); + let mut l2_handle_samples: Vec = Vec::with_capacity(args.pairs); for pair in 0..args.pairs { // Cold-ish L3 fill: the OS page cache will help after warmup, which @@ -129,32 +181,44 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { .context("bench L3 fill missed")?; let l3_ns = start.elapsed().as_nanos(); - let start = Instant::now(); + // Handle-only lookup, reported separately. + let handle_start = Instant::now(); let hit = l2.get(&cache_key); + let l2_handle_ns = handle_start.elapsed().as_nanos(); + + // Timed through the same usable-bytes boundary as the L3 arm: the + // L3 timer covers disk assembly + verification, so the L2 timer + // covers handle lookup + assembly + materialization. + let start = Instant::now(); let l2_payload = hit.as_ref().map(|hit| hit.to_payload()); + let (l2_bytes, _) = l2_payload + .as_ref() + .context("bench L2 payload missing")? + .full_state_bytes_timed() + .context("bench L2 bytes")?; let l2_ns = start.elapsed().as_nanos(); // Correctness gate, outside the timer: L2 must return // byte-identical state to the L3 fill, or the speedup is // meaningless. let hit = hit.context("bench L2 lookup missed")?; - let l2_payload = l2_payload.context("bench L2 payload missing")?; anyhow::ensure!(hit.token_count == fill.token_count); anyhow::ensure!(hit.payload_digest == payload_digest); let (l3_bytes, _) = fill.payload.full_state_bytes_timed().context("l3 bytes")?; - let (l2_bytes, _) = l2_payload.full_state_bytes_timed().context("l2 bytes")?; anyhow::ensure!( - l3_bytes == l2_bytes, + l3_bytes.as_ref() == l2_bytes.as_ref(), "pair {pair}: L2 payload diverged from L3 fill" ); l3_samples.push(l3_ns); l2_samples.push(l2_ns); + l2_handle_samples.push(l2_handle_ns); } let stats = l2.stats(); let mut l3_sorted = l3_samples.clone(); let mut l2_sorted = l2_samples.clone(); + let mut l2_handle_sorted = l2_handle_samples.clone(); let summary = serde_json::json!({ "bench": "l2-tier", "pairs": args.pairs, @@ -166,10 +230,14 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { "p50": percentile(&mut l3_sorted, 50.0), "p99": percentile(&mut l3_sorted, 99.0), }, - "l2_lookup_assembly_handle_ns": { + "l2_lookup_to_usable_bytes_ns": { "p50": percentile(&mut l2_sorted, 50.0), "p99": percentile(&mut l2_sorted, 99.0), }, + "l2_handle_lookup_ns": { + "p50": percentile(&mut l2_handle_sorted, 50.0), + "p99": percentile(&mut l2_handle_sorted, 99.0), + }, "l2_admission_hash_ns_one_time": admission_hash_ns, "speedup_p50": percentile(&mut l3_sorted, 50.0) / percentile(&mut l2_sorted, 50.0).max(1.0), "l2_stats": { @@ -185,7 +253,56 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { println!("{summary}"); if !args.keep_store { - let _ = std::fs::remove_dir_all(&args.store_root); + remove_owned_store_root(&store_root)?; } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_root_refuses_existing_paths() { + let dir = + std::env::temp_dir().join(format!("skippy-l2-bench-refuse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create pre-existing dir"); + std::fs::write(dir.join("precious.txt"), b"user data").expect("seed user data"); + + let err = prepare_store_root(&dir).expect_err("existing path must be refused"); + assert!( + err.to_string().contains("refusing to use store root"), + "unexpected error: {err}" + ); + assert!( + dir.join("precious.txt").is_file(), + "pre-existing contents must survive the refusal" + ); + + // A root the bench created is removable; a lookalike without the + // sentinel is not. (Creation itself stays refused for any + // pre-existing path, owned or not.) + let _ = std::fs::remove_dir_all(&dir); + + let owned = + std::env::temp_dir().join(format!("skippy-l2-bench-owned-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&owned); + prepare_store_root(&owned).expect("fresh root is created and owned"); + assert!(owned.join(OWNERSHIP_SENTINEL).is_file()); + remove_owned_store_root(&owned).expect("owned root is removable"); + assert!(!owned.exists()); + + // Unmarked directory: cleanup must refuse. + let unowned = + std::env::temp_dir().join(format!("skippy-l2-bench-unowned-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&unowned); + std::fs::create_dir_all(&unowned).expect("create unowned dir"); + std::fs::write(unowned.join("keep.txt"), b"user data").expect("seed"); + let err = + remove_owned_store_root(&unowned).expect_err("unowned root removal must be refused"); + assert!(err.to_string().contains("sentinel"), "unexpected: {err}"); + assert!(unowned.join("keep.txt").is_file(), "contents survive"); + let _ = std::fs::remove_dir_all(&unowned); + } +} diff --git a/crates/skippy-cache/src/l2/mod.rs b/crates/skippy-cache/src/l2/mod.rs index bb20e1eee1..fb617e7117 100644 --- a/crates/skippy-cache/src/l2/mod.rs +++ b/crates/skippy-cache/src/l2/mod.rs @@ -167,7 +167,20 @@ impl ExactStatePayloadMirror { manifest .segments .iter() - .map(|segment| { + .enumerate() + .map(|(index, segment)| { + if segment.index != index as u32 { + return Err(L2InsertRefusal::MalformedManifest(format!( + "segment {} records index {} but sits at position {index}", + segment.digest, segment.index + ))); + } + if segment.offset != offset { + return Err(L2InsertRefusal::MalformedManifest(format!( + "segment {} records offset {} but tiles at {offset}", + segment.digest, segment.offset + ))); + } let start = offset; offset = offset.checked_add(segment.bytes).ok_or( L2InsertRefusal::MalformedManifest("segment tiling overflows".to_string()), @@ -313,6 +326,22 @@ pub enum L2InsertRefusal { expected: String, actual: String, }, + /// A layout segment's claimed digest does not match the BLAKE3 of its + /// exact wire range: the pool identity would not describe the bytes it + /// is supposed to serve. + SegmentDigestMismatch { + digest: String, + expected: String, + actual: String, + }, + /// A layout claims a segment digest for two different wire ranges, or + /// claims a digest the pool already holds with different content that + /// another live entry still references. Content-addressed identity must + /// stay unambiguous. + ConflictingSegment { + digest: String, + detail: String, + }, UnknownPayloadKind(String), MalformedManifest(String), } @@ -332,6 +361,17 @@ impl L2InsertRefusal { "admission digest check failed: wire hashes to {actual} but the payload \ claims {expected}" ), + Self::SegmentDigestMismatch { + digest, + expected, + actual, + } => format!( + "segment {digest} does not describe its wire range: range hashes to {actual} \ + but the layout claims {expected}" + ), + Self::ConflictingSegment { digest, detail } => { + format!("conflicting claims for segment {digest}: {detail}") + } Self::UnknownPayloadKind(kind) => { format!("manifest holds unknown payload kind {kind}") } @@ -400,6 +440,80 @@ fn is_valid_digest(digest: &str) -> bool { hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit()) } +/// One validated layout segment: its claimed digest and the exact verified +/// slice of the wire it describes. +type ValidatedSegment<'a> = (&'a str, &'a [u8]); + +/// Fully validate a mirror's layout against the verified wire *before* any +/// pool mutation: +/// +/// - the tiling is non-empty and contiguous over `0..total_bytes`; +/// - `kv_bytes + recurrent_bytes == total_bytes`; +/// - every segment digest is well-formed and hashes its exact wire range. +/// +/// A layout that fails any check is refused (`MalformedManifest` or +/// `SegmentDigestMismatch`): the pool is content-addressed, so a handle's +/// digest must describe the bytes it serves. +fn validate_layout<'a>( + mirror: &'a ExactStatePayloadMirror, + wire: &'a [u8], +) -> Result>, L2InsertRefusal> { + let layout = mirror.layout(); + if layout.segments.is_empty() { + return Err(L2InsertRefusal::MalformedManifest( + "layout holds no segments".to_string(), + )); + } + if layout.total_bytes != wire.len() as u64 { + return Err(L2InsertRefusal::MalformedManifest(format!( + "layout claims {total} total bytes but the wire holds {len}", + total = layout.total_bytes, + len = wire.len() + ))); + } + if layout.kv_bytes.saturating_add(layout.recurrent_bytes) != layout.total_bytes { + return Err(L2InsertRefusal::MalformedManifest(format!( + "kv ({kv}) + recurrent ({rec}) bytes do not tile the {total}-byte payload", + kv = layout.kv_bytes, + rec = layout.recurrent_bytes, + total = layout.total_bytes + ))); + } + let mut expected_start = 0u64; + let mut validated = Vec::with_capacity(layout.segments.len()); + for (digest, range) in &layout.segments { + if !is_valid_digest(digest) { + return Err(L2InsertRefusal::MalformedManifest(format!( + "segment digest {digest:?} is not a blake3 hex digest" + ))); + } + if range.start != expected_start || range.end < range.start || range.end > wire.len() as u64 + { + return Err(L2InsertRefusal::MalformedManifest(format!( + "segment range {range:?} does not contiguously tile the wire at offset \ + {expected_start}" + ))); + } + expected_start = range.end; + let slice = &wire[range.start as usize..range.end as usize]; + let actual = segment_digest(slice); + if actual != *digest { + return Err(L2InsertRefusal::SegmentDigestMismatch { + digest: digest.clone(), + expected: digest.clone(), + actual, + }); + } + validated.push((digest.as_str(), slice)); + } + if expected_start != layout.total_bytes { + return Err(L2InsertRefusal::MalformedManifest(format!( + "segments tile {expected_start} bytes but the layout claims {}", + layout.total_bytes + ))); + } + Ok(validated) +} impl L2Tier { pub fn new(budget_bytes: u64) -> Self { Self { @@ -417,15 +531,22 @@ impl L2Tier { /// /// `wire` is the payload's concatenated L3 wire — the exact bytes whose /// BLAKE3 is the manifest key. Admission verifies - /// `segment_digest(&wire) == payload_digest` exactly once and refuses - /// the insert on mismatch: L2 never holds bytes it did not verify. - /// After admission the segment bytes are immutable, so reads are a - /// digest lookup plus handle assembly — no re-hash. + /// `segment_digest(&wire) == payload_digest` and refuses the insert on + /// mismatch: L2 never holds bytes it did not verify. Every layout + /// segment's digest is verified against its exact wire range before the + /// pool is touched, so a handle can never serve bytes its digest does + /// not describe. After admission the segment bytes are immutable, so + /// reads are a digest lookup plus handle assembly — no re-hash. /// - /// Returns the evictions the admission caused, so callers and tests can - /// assert policy. The budget is charged with the entry's *distinct* - /// segment bytes: segments already held by another entry are shared, - /// not duplicated, and only the first admission pays for them. + /// The admission is atomic with respect to the segment pool: the + /// incoming layout's segments are installed (or reserved) *before* the + /// previous entry at the same key is released and before eviction runs, + /// with those handles pinned against removal, so a replacement or a + /// sharing admission can never release a handle it is about to + /// reference. Returns the evictions the admission caused. The budget is + /// charged with the entry's *distinct* segment bytes: segments already + /// held by another entry are shared, not duplicated, and only the first + /// admission pays for them. pub fn admit( &self, cache_key: String, @@ -439,8 +560,8 @@ impl L2Tier { self.stats.admission_rejects.fetch_add(1, Ordering::Relaxed); return Err(L2InsertRefusal::MalformedDigest); } - // The one integrity check: the wire must hash to the claimed - // manifest-key digest. + // The one integrity check on the whole wire: it must hash to the + // claimed manifest-key digest. let actual = segment_digest(wire); if actual != payload_digest { self.stats.admission_rejects.fetch_add(1, Ordering::Relaxed); @@ -460,42 +581,73 @@ impl L2Tier { wire.len() ))); } + // Every segment slice is hashed against its claimed digest before + // any pool mutation: pool reuse trusts content, not digest text. + let segments = validate_layout(&mirror, wire)?; + let mut inner = self.inner.lock().expect("L2 map lock poisoned"); - // Distinct-byte charge: segments the pool already holds (shared - // prefix with another entry) cost nothing new, provided the pool's - // copy still covers the segment's full length. Anything else is cut - // out of the verified wire. + // Distinct-byte charge: segments the pool already holds with + // exactly the verified content are shared (anything else conflicts + // or is new). `shared` pins every handle this admission references + // — including segments still owned by the entry being replaced — + // so release and eviction below cannot drop them out from under + // the transaction. + let mut shared: Vec = Vec::new(); let mut new_segments: Vec<(String, SegmentHandle)> = Vec::new(); let mut new_bytes = 0u64; let mut shared_bytes = 0u64; - for (digest, range) in &mirror.layout().segments { - let expected_len = (range.end.saturating_sub(range.start)) as usize; - if let Some(handle) = inner.segments.get(digest) { - if handle.bytes.len() == expected_len { - shared_bytes += expected_len as u64; - continue; + for &(digest, slice) in segments.iter() { + if new_segments.iter().any(|(d, _)| d == digest) { + // Within-admission duplicate digest: the validation pass + // already proved both slices have identical content, so + // keep the first copy. + shared_bytes += slice.len() as u64; + continue; + } + match inner.segments.get(digest) { + // Pool already holds this exact content: share it, whoever + // currently owns it. + Some(handle) if handle.bytes.as_ref() == slice => { + shared_bytes += slice.len() as u64; + shared.push(digest.to_string()); } - // Pool copy disagrees with the verified wire: replace it. - let stale = inner.segments.remove(digest); - if let Some(handle) = stale { - inner.bytes = inner.bytes.saturating_sub(handle.bytes.len() as u64); + // Same digest text, different bytes in the pool. + Some(_) => { + // Replacing an entry at the same key legitimately + // re-uses a digest with new content: the old owner is + // about to be released. Anything else is a conflict — + // a stale handle another live entry still references + // must never be swapped underneath it. + let replacing_same_key = inner + .map + .get(&cache_key) + .is_some_and(|entry| entry.payload.segment_digests().contains(&digest)); + if !replacing_same_key { + return Err(L2InsertRefusal::ConflictingSegment { + digest: digest.to_string(), + detail: "the pool holds different bytes under this digest \ + for another live entry" + .to_string(), + }); + } + new_bytes = new_bytes.saturating_add(slice.len() as u64); + new_segments.push(( + digest.to_string(), + SegmentHandle { + bytes: Arc::new(slice.to_vec()), + }, + )); } - } - if let Some(position) = new_segments.iter().position(|(d, _)| d == digest) { - // Within-admission duplicate: keep the first copy. - let existing = &new_segments[position].1; - if existing.bytes.len() == expected_len { - shared_bytes += expected_len as u64; - continue; + None => { + new_bytes = new_bytes.saturating_add(slice.len() as u64); + new_segments.push(( + digest.to_string(), + SegmentHandle { + bytes: Arc::new(slice.to_vec()), + }, + )); } - new_bytes = new_bytes.saturating_sub(existing.bytes.len() as u64); - new_segments.remove(position); } - let start = (range.start as usize).min(wire.len()); - let end = (range.end as usize).min(wire.len()); - let bytes = Arc::new(wire[start..end].to_vec()); - new_bytes = new_bytes.saturating_add(bytes.len() as u64); - new_segments.push((digest.to_string(), SegmentHandle { bytes })); } if new_bytes > self.budget_bytes { self.stats @@ -505,21 +657,55 @@ impl L2Tier { payload_bytes: new_bytes, }); } + // Reserve the new handles in the pool before releasing anything, + // so a digest re-used with new content is unambiguous from here on + // and the incoming entry's bytes cannot be dropped mid-transaction. + for (digest, handle) in &new_segments { + inner.bytes = inner.bytes.saturating_add(handle.bytes.len() as u64); + inner.segments.insert(digest.clone(), handle.clone()); + } + let protected: Vec = new_segments + .iter() + .map(|(digest, _)| digest.clone()) + .chain(shared.iter().cloned()) + .collect(); // One entry per cache key: a re-admit at the same coordinates is a // replacement (fresher state for the same prefix), not a duplicate. + // The old entry's segments survive release where the incoming + // layout shares them (`protected`), so identical-wire re-admits + // never delete their own handles. if let Some(existing) = inner.map.remove(&cache_key) { - self.release_entry_segments(&mut inner, &existing); + self.release_entry_segments(&mut inner, &existing, &protected); + } + // Evict to make room: the reservation already counts toward + // `inner.bytes`, so the pool (including this admission's distinct + // bytes) must fit the whole budget. Shared handles are pinned and + // can keep a victim from freeing — those retained bytes transfer + // to this entry's charge below. + let evictions = self.evict_to_limit(&mut inner, self.budget_bytes, &cache_key, &protected); + // A victim that shared segments with this admission freed nothing: + // those bytes are now exclusively this entry's, so the charge must + // include them. (The pool may then sit above budget by exactly the + // pinned bytes the victim could not release — bounded by this + // entry's own wire.) + let mut charge_bytes = new_bytes; + for digest in &shared { + let still_shared = inner + .map + .values() + .any(|other| other.payload.segment_digests().contains(&digest.as_str())); + if !still_shared { + charge_bytes = charge_bytes.saturating_add( + inner + .segments + .get(digest) + .map(|handle| handle.bytes.len() as u64) + .unwrap_or(0), + ); + } } - // Evict to make room BEFORE the new segments land: the projected - // footprint is the live pool plus this admission's distinct bytes. - let headroom = self.budget_bytes.saturating_sub(new_bytes); - let evictions = self.evict_to_limit(&mut inner, headroom, &cache_key); inner.clock = inner.clock.wrapping_add(1); let last_used = inner.clock; - for (digest, handle) in new_segments { - inner.bytes = inner.bytes.saturating_add(handle.bytes.len() as u64); - inner.segments.insert(digest, handle); - } self.stats .shared_bytes_admitted .fetch_add(shared_bytes, Ordering::Relaxed); @@ -531,7 +717,7 @@ impl L2Tier { payload_digest, origin, last_used, - charge_bytes: new_bytes, + charge_bytes, payload_bytes, }, ); @@ -543,23 +729,57 @@ impl L2Tier { /// `Arc` clones of its segment handles (no byte copies). Digests are /// not re-hashed: admission verified the wire, and segments are /// immutable afterward. + /// + /// If a segment handle the entry references is missing from the pool, + /// the entry is corrupt: the hit is downgraded to a miss, the entry and + /// its surviving segments are removed, LRU recency never moves, and the + /// miss counter is incremented. pub fn get(&self, cache_key: &str) -> Option { let mut inner = self.inner.lock().expect("L2 map lock poisoned"); + let digests: Vec = match inner.map.get(cache_key) { + Some(entry) => entry + .payload + .segment_digests() + .into_iter() + .map(str::to_string) + .collect(), + // Absent key: a miss, never an LRU touch. + None => { + self.stats.misses.fetch_add(1, Ordering::Relaxed); + return None; + } + }; + let missing = digests + .iter() + .any(|digest| !inner.segments.contains_key(digest)); + if missing { + // Corrupt entry: a segment handle vanished without an entry + // removal. Serve a miss, never partial bytes; drop the entry + // and its surviving handles; recency stays untouched. + let removed = inner.map.remove(cache_key); + if let Some(entry) = removed { + self.release_entry_segments(&mut inner, &entry, &[]); + } + self.stats.misses.fetch_add(1, Ordering::Relaxed); + return None; + } let now = { inner.clock = inner.clock.wrapping_add(1); inner.clock }; - let entry = inner.map.get_mut(cache_key)?; + let entry = inner.map.get_mut(cache_key).expect("entry checked above"); entry.last_used = now; let payload = entry.payload.clone(); let token_count = entry.token_count; let payload_digest = entry.payload_digest.clone(); self.stats.hits.fetch_add(1, Ordering::Relaxed); - let mut segments = HashMap::with_capacity(payload.segment_digests().len()); - for digest in payload.segment_digests() { - if let Some(handle) = inner.segments.get(digest) { - segments.insert(digest.to_string(), handle.clone()); - } + let mut segments = HashMap::with_capacity(digests.len()); + for digest in &digests { + let handle = inner + .segments + .get(digest) + .expect("all digests checked above"); + segments.insert(digest.clone(), handle.clone()); } Some(L2Hit { payload, @@ -589,7 +809,7 @@ impl L2Tier { let mut inner = self.inner.lock().expect("L2 map poisoned"); let removed = inner.map.remove(cache_key)?; let before = inner.bytes; - self.release_entry_segments(&mut inner, &removed); + self.release_entry_segments(&mut inner, &removed, &[]); let freed = before.saturating_sub(inner.bytes); Some(L2Eviction { cache_key: cache_key.to_string(), @@ -620,6 +840,15 @@ impl L2Tier { self.len() == 0 } + /// Test-only: drop every pool handle without touching the entry map, + /// to force the missing-handle corruption path in `get`. + #[cfg(test)] + fn clear_pool_for_test(&self) { + let mut inner = self.inner.lock().expect("L2 map lock poisoned"); + inner.segments.clear(); + inner.bytes = 0; + } + /// Point-in-time snapshot combining atomics with the locked totals. pub fn stats(&self) -> L2Stats { let inner = self.inner.lock().expect("L2 map lock poisoned"); @@ -645,12 +874,16 @@ impl L2Tier { } } - /// Drop an entry's exclusive segments from the pool, decrementing the - /// pool byte total. Shared segments stay: another entry still - /// references them. Zero-byte segments are dropped without accounting - /// (a pool without payload bytes must never charge the budget). - fn release_entry_segments(&self, inner: &mut L2Inner, entry: &L2Entry) { + /// Drop an entry's segments from the pool, decrementing the pool byte + /// total. Segments still referenced by another live entry, or pinned by + /// an in-flight admission (`protected`), stay. Zero-byte segments are + /// dropped without accounting (a pool without payload bytes must never + /// charge the budget). + fn release_entry_segments(&self, inner: &mut L2Inner, entry: &L2Entry, protected: &[String]) { for digest in entry.payload.segment_digests() { + if protected.iter().any(|p| p == digest) { + continue; + } let still_referenced = inner .map .values() @@ -667,12 +900,15 @@ impl L2Tier { /// Evict in deterministic LRU order until the pool fits `limit` bytes. /// Shared segments are released only with their last referencing - /// entry; a victim that frees nothing is still counted as an eviction. + /// entry; handles pinned by the in-flight admission (`protected`) are + /// never released; a victim that frees nothing is still counted as an + /// eviction. fn evict_to_limit( &self, inner: &mut L2Inner, limit: u64, protect_key: &str, + protected: &[String], ) -> Vec { let mut evictions = Vec::new(); while inner.bytes > limit { @@ -690,7 +926,7 @@ impl L2Tier { break; }; let before = inner.bytes; - self.release_entry_segments(inner, &removed); + self.release_entry_segments(inner, &removed, protected); let freed = before.saturating_sub(inner.bytes); self.stats.evictions.fetch_add(1, Ordering::Relaxed); evictions.push(L2Eviction { @@ -1246,14 +1482,14 @@ mod tests { index: 0, offset: 0, bytes: 16, - digest: "blake3:seg-a".to_string(), + digest: segment_digest(&wire_bytes[..16]), meta_json: None, }, HandoffSegmentRef { index: 1, offset: 16, bytes: 16, - digest: "blake3:seg-b".to_string(), + digest: segment_digest(&wire_bytes[16..]), meta_json: None, }, ], @@ -1394,4 +1630,221 @@ mod tests { assert_eq!(tier.stats().bytes, 0); assert_eq!(tier.stats().segments, 0); } + + #[test] + fn identical_wire_same_key_readmit_keeps_its_own_segments() { + // The original failure: re-admitting an identical wire at the same + // key classified the existing pool segments as shared, released the + // old entry's last references, inserted no replacement handles, and + // left an entry whose segments were absent + // (`declared=14 restored=0 pool=0`). + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[11]); + let segment_len = 16u64; + let (w, digest) = wire(64, 21); + for round in 0..3 { + let mirror = manifest_shaped_mirror(&w, segment_len); + tier.admit(k.clone(), 4, digest.clone(), &w, mirror, L2Origin::FromL3) + .expect("identical re-admit must be accepted"); + let hit = tier + .get(&k) + .unwrap_or_else(|| panic!("round {round}: re-admitted key must hit")); + let payload = hit.to_payload(); + let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!( + bytes.as_ref(), + &w[..], + "round {round}: re-admitted entry must serve its full wire" + ); + let stats = tier.stats(); + assert_eq!(stats.entries, 1); + assert_eq!( + stats.segments, 4, + "round {round}: pool must still hold every segment" + ); + assert_eq!(stats.bytes, 64, "round {round}: pool bytes exact"); + } + } + + #[test] + fn eviction_cannot_release_segments_the_incoming_entry_shares() { + // Pressure case: the incoming entry shares its would-be victim's + // segments. The victim is not protected by the cache-key filter + // (different key) and is not yet replaced in the map, so eviction + // could drop the shared handles before the new entry lands. + let segment_len = 16u64; + let total = segment_len * 4; + // Budget forces eviction: the reserved pool (64 bytes) exceeds it + // by one byte until the old entry releases its exclusive segment. + let tier = L2Tier::new(total - 1); + let (w, _) = wire(total as usize, 30); + let short_len = segment_len * 3; + + let old = key("ns", &[1]); + tier.admit( + old.clone(), + 3, + segment_digest(&w[..short_len as usize]), + &w[..short_len as usize], + manifest_shaped_mirror(&w[..short_len as usize], segment_len), + L2Origin::FromL3, + ) + .expect("old entry admitted"); + + // New key whose wire extends the old entry's segments; the budget + // forces eviction of the old entry during this admission. + let grown = key("ns", &[2]); + let evictions = tier + .admit( + grown.clone(), + 4, + segment_digest(&w[..total as usize]), + &w[..total as usize], + manifest_shaped_mirror(&w, segment_len), + L2Origin::FromL3, + ) + .expect("admission must succeed by evicting the old entry"); + assert_eq!(evictions.len(), 1, "old entry is the victim"); + assert_eq!(evictions[0].cache_key, old); + + // The shared prefix segments must have survived the eviction. + let hit = tier.get(&grown).expect("grown entry hits"); + let payload = hit.to_payload(); + let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!( + bytes.as_ref(), + &w[..total as usize], + "shared segments must survive the admission that evicted their old owner" + ); + let stats = tier.stats(); + assert_eq!(stats.entries, 1); + assert_eq!(stats.segments, 4); + assert_eq!(stats.bytes, total); + assert!(tier.peek(&old).is_none(), "old entry was evicted"); + } + + #[test] + fn same_digest_different_bytes_is_rejected_unless_replacing_same_key() { + // A second wire claiming an existing segment digest with + // different-length content must not steal or replace the live + // pool handle. + let tier = L2Tier::new(1 << 20); + let (w1, _) = wire(32, 41); + let k1 = key("ns", &[1]); + tier.admit( + k1.clone(), + 2, + segment_digest(&w1), + &w1, + single_segment_mirror(&w1), + L2Origin::FromL3, + ) + .expect("first entry admitted"); + + // Forge a fake digest; the wire integrity check would reject a + // mismatched whole-wire digest, so claim the real segment digest + // of another wire as the *layout segment* digest instead. Build a + // second wire whose layout claims k1's segment digest. + let (w2, d2) = wire(48, 42); + let stolen = segment_digest(&w1); + let mirror = ExactStatePayloadMirror::FullState { + layout: L2Layout { + payload_kind: ExactStatePayloadKind::FullState, + total_bytes: 48, + kv_bytes: 48, + recurrent_bytes: 0, + segments: vec![(stolen, 0..24), (segment_digest(&w2[24..]), 24..48)], + }, + }; + let k2 = key("ns", &[2]); + let err = tier + .admit(k2, 3, d2, &w2, mirror, L2Origin::Direct) + .expect_err( + "a layout that claims another entry's digest with different bytes \ + must be refused", + ); + assert!( + matches!(err, L2InsertRefusal::SegmentDigestMismatch { .. }), + "expected segment digest mismatch, got: {err:?}" + ); + // The first entry's handle is untouched and still serves its bytes. + let hit = tier.get(&k1).expect("first entry intact"); + let payload = hit.to_payload(); + let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!(bytes.as_ref(), &w1[..]); + } + + #[test] + fn get_with_missing_segment_handle_is_a_cold_miss_without_recency() { + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[5]); + let (w, d) = wire(64, 51); + tier.admit( + k.clone(), + 4, + d, + &w, + manifest_shaped_mirror(&w, 16), + L2Origin::FromL3, + ) + .expect("admitted"); + + // Simulate corruption: drop one handle directly out of the pool + // (test-only access through the public remove on a scratch entry + // would also free it, but here we remove the pool entry via the + // tier's own release path by admitting an exclusive same-digest + // layout is impossible — so exercise via a second tier sharing + // nothing is not needed; directly verify the downgrade path by + // clearing the pool). + tier.clear_pool_for_test(); + + let hit = tier.get(&k); + assert!(hit.is_none(), "missing handles must downgrade to a miss"); + let stats = tier.stats(); + assert_eq!(stats.misses, 1, "the miss counter must move"); + assert_eq!(stats.hits, 0); + // The corrupt entry is removed: the next get is also a miss, not a + // partial hit, and no panic occurs. + assert!(tier.get(&k).is_none()); + assert_eq!(tier.stats().misses, 2); + assert!( + tier.peek(&k).is_none(), + "corrupt entry must be dropped, not left peekable" + ); + } + + #[test] + fn from_manifest_rejects_wrong_segment_index_and_offset() { + let (w, digest) = wire(32, 61); + let base = |index: u32, offset: u64, digest: String| HandoffManifest { + version: MANIFEST_VERSION, + model_identity: "m".to_string(), + state_identity: "s".to_string(), + payload_kind: "full-state".to_string(), + total_bytes: 32, + payload_digest: digest.clone(), + segments: vec![HandoffSegmentRef { + index, + offset, + bytes: 32, + digest: segment_digest(&w), + meta_json: None, + }], + kv_bytes: 32, + recurrent_bytes: 0, + kv_desc_json: None, + token_count: 1, + continuation_token: 0, + expected_tokens: Vec::new(), + }; + let manifest = base(1, 0, digest.clone()); + let err = ExactStatePayloadMirror::from_manifest(&manifest) + .expect_err("wrong segment index must be refused"); + assert!(matches!(err, L2InsertRefusal::MalformedManifest(_))); + + let manifest = base(0, 8, digest); + let err = ExactStatePayloadMirror::from_manifest(&manifest) + .expect_err("wrong segment offset must be refused"); + assert!(matches!(err, L2InsertRefusal::MalformedManifest(_))); + } } From 911d56f0ca929c7d5faad546dd633c20b91aa829 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:15:57 +0000 Subject: [PATCH 20/41] v0.76.0: prepare release source --- Cargo.lock | 128 +++++++++--------- Cargo.toml | 8 +- crates/mesh-client/Cargo.toml | 10 +- crates/mesh-llm-api-client/Cargo.toml | 2 +- crates/mesh-llm-api-server/Cargo.toml | 4 +- crates/mesh-llm-cli/Cargo.toml | 2 +- crates/mesh-llm-commands/Cargo.toml | 26 ++-- crates/mesh-llm-config/Cargo.toml | 4 +- .../src/model/built_in_schema/presentation.rs | 2 +- .../model/built_in_schema/setting_schema.rs | 1 + crates/mesh-llm-console-server/Cargo.toml | 2 +- crates/mesh-llm-embedded-runtime/Cargo.toml | 2 +- crates/mesh-llm-hardware-profile/Cargo.toml | 2 +- crates/mesh-llm-host-runtime/Cargo.toml | 68 +++++----- .../fixtures/config_schema_reference.json | 2 +- crates/mesh-llm-log-store/Cargo.toml | 2 +- crates/mesh-llm-native-runtime/README.md | 12 +- crates/mesh-llm-node/Cargo.toml | 8 +- crates/mesh-llm-nodejs/Cargo.toml | 2 +- crates/mesh-llm-runtime-install/Cargo.toml | 6 +- crates/mesh-llm-sdk/Cargo.toml | 10 +- crates/mesh-llm-sdk/README.md | 6 +- crates/mesh-llm-system/Cargo.toml | 8 +- crates/mesh-llm-tui/Cargo.toml | 2 +- crates/mesh-llm-ui/package-lock.json | 4 +- crates/mesh-llm-ui/package.json | 2 +- crates/mesh-llm/Cargo.toml | 16 +-- crates/mesh-mixture-of-agents/Cargo.toml | 2 +- .../Cargo.toml | 8 +- crates/model-artifact/Cargo.toml | 2 +- crates/model-hf/Cargo.toml | 4 +- crates/model-package/Cargo.toml | 4 +- crates/model-resolver/Cargo.toml | 4 +- crates/openai-frontend/Cargo.toml | 4 +- crates/skippy-cache/Cargo.toml | 2 +- crates/skippy-model/Cargo.toml | 2 +- crates/skippy-protocol/Cargo.toml | 2 +- crates/skippy-runtime/Cargo.toml | 4 +- crates/skippy-scheduler/Cargo.toml | 4 +- crates/skippy-server/Cargo.toml | 24 ++-- docs/SDK.md | 4 +- docs/design/NATIVE_RUNTIMES.md | 4 +- docs/plugins/exemplars/web-ui/Cargo.lock | 2 +- docs/sdk/rust.md | 4 +- docs/sdk/swift.md | 2 +- sdk/kotlin/README.md | 2 +- sdk/kotlin/build.gradle.kts | 2 +- .../example/example-jvm/build.gradle.kts | 2 +- sdk/node/package.json | 2 +- sdk/swift/README.md | 2 +- sdk/swift/scripts/generate-swift-bindings.sh | 2 +- website/src/docs/pages/CLI.md | 4 +- website/src/docs/pages/developing-plugins.md | 2 +- 53 files changed, 221 insertions(+), 220 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a09b62ff7..8820d24318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3532,14 +3532,14 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-quant-ffi" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "libloading", ] [[package]] name = "llama-spec-bench" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "clap", @@ -3678,7 +3678,7 @@ dependencies = [ [[package]] name = "mesh-llm" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "axum", @@ -3706,7 +3706,7 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "hex", "mesh-llm-client", @@ -3716,7 +3716,7 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -3726,11 +3726,11 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.76.0-rc9" +version = "0.76.0" [[package]] name = "mesh-llm-cli" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "clap", @@ -3742,7 +3742,7 @@ dependencies = [ [[package]] name = "mesh-llm-client" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "async-trait", @@ -3769,7 +3769,7 @@ dependencies = [ [[package]] name = "mesh-llm-commands" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "chrono", @@ -3813,7 +3813,7 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "dirs", @@ -3830,7 +3830,7 @@ dependencies = [ [[package]] name = "mesh-llm-console-server" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "mesh-llm-ui", @@ -3839,7 +3839,7 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -3848,7 +3848,7 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "chrono", @@ -3865,7 +3865,7 @@ dependencies = [ [[package]] name = "mesh-llm-ffi" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "mesh-llm-node", "mesh-llm-sdk", @@ -3876,7 +3876,7 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "serde", "serde_json", @@ -3885,7 +3885,7 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "serde", "serde_json", @@ -3893,7 +3893,7 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "mesh-llm-native-runtime", ] @@ -3929,7 +3929,7 @@ dependencies = [ [[package]] name = "mesh-llm-host-runtime" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "argon2", @@ -4027,7 +4027,7 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "apple-native-keyring-store", "argon2", @@ -4050,7 +4050,7 @@ dependencies = [ [[package]] name = "mesh-llm-log-store" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "chrono", "data-encoding", @@ -4068,7 +4068,7 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "hex", @@ -4080,7 +4080,7 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "mesh-llm-types", @@ -4094,7 +4094,7 @@ dependencies = [ [[package]] name = "mesh-llm-nodejs" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "mesh-llm-sdk", "napi", @@ -4106,7 +4106,7 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "async-trait", @@ -4122,7 +4122,7 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "dirs", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "hex", @@ -4153,7 +4153,7 @@ dependencies = [ [[package]] name = "mesh-llm-release-footer" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "hex", "serde", @@ -4163,7 +4163,7 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "blake3", "iroh", @@ -4173,7 +4173,7 @@ dependencies = [ [[package]] name = "mesh-llm-runtime-install" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "dirs", @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "dirs", @@ -4221,7 +4221,7 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "chrono", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "mesh-llm-test-harness" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "reqwest", "serde_json", @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "mesh-llm-tui" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "ansi-to-tui", "anyhow", @@ -4274,7 +4274,7 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "hex", "serde", @@ -4284,14 +4284,14 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "include_dir", ] [[package]] name = "mesh-mixture-of-agents" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -4304,11 +4304,11 @@ dependencies = [ [[package]] name = "mesh-native-serving-plugin-api" -version = "0.76.0-rc9" +version = "0.76.0" [[package]] name = "mesh-native-serving-plugin-host" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "libloading", @@ -4319,7 +4319,7 @@ dependencies = [ [[package]] name = "metrics-server" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "axum", @@ -4390,7 +4390,7 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "async-trait", @@ -4401,7 +4401,7 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "async-trait", @@ -4424,7 +4424,7 @@ dependencies = [ [[package]] name = "model-package" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "bytes", @@ -4443,14 +4443,14 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "model-artifact", @@ -5301,7 +5301,7 @@ checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" [[package]] name = "openai-frontend" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "async-trait", "axum", @@ -7365,7 +7365,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-bench" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "clap", @@ -7385,7 +7385,7 @@ dependencies = [ [[package]] name = "skippy-cache" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "blake3", @@ -7394,14 +7394,14 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "thiserror 2.0.20", ] [[package]] name = "skippy-correctness" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "clap", @@ -7419,18 +7419,18 @@ dependencies = [ [[package]] name = "skippy-ffi" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.76.0-rc9" +version = "0.76.0" [[package]] name = "skippy-model" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "clap", @@ -7443,7 +7443,7 @@ dependencies = [ [[package]] name = "skippy-model-package" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "clap", @@ -7466,7 +7466,7 @@ dependencies = [ [[package]] name = "skippy-package-format" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "serde", "serde_json", @@ -7475,7 +7475,7 @@ dependencies = [ [[package]] name = "skippy-prompt" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "blake3", @@ -7492,7 +7492,7 @@ dependencies = [ [[package]] name = "skippy-protocol" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "prost", "prost-build", @@ -7504,7 +7504,7 @@ dependencies = [ [[package]] name = "skippy-quantize" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "clap", @@ -7520,7 +7520,7 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "libc", @@ -7535,7 +7535,7 @@ dependencies = [ [[package]] name = "skippy-scheduler" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "serde", "serde_json", @@ -7546,7 +7546,7 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "ahash", "anyhow", @@ -7584,7 +7584,7 @@ dependencies = [ [[package]] name = "skippy-tokenizer" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "serde", "serde_json", @@ -7592,7 +7592,7 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "serde", "serde_json", @@ -9790,7 +9790,7 @@ dependencies = [ [[package]] name = "xtask" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "ed25519-dalek", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index 1cce17838c..768d276270 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,16 +74,16 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT OR Apache-2.0" -version = "0.76.0-rc9" +version = "0.76.0" [workspace.dependencies] ahash = "0.8.12" anyhow = "1" blake3 = "1" clap = { version = "4", features = ["derive"] } -mesh-llm-build-info = { path = "crates/mesh-llm-build-info", version = "0.76.0-rc9" } -mesh-llm-release-footer = { path = "crates/mesh-llm-release-footer", version = "0.76.0-rc9" } -mesh-llm-skills = { path = "crates/mesh-llm-skills", version = "0.76.0-rc9" } +mesh-llm-build-info = { path = "crates/mesh-llm-build-info", version = "0.76.0" } +mesh-llm-release-footer = { path = "crates/mesh-llm-release-footer", version = "0.76.0" } +mesh-llm-skills = { path = "crates/mesh-llm-skills", version = "0.76.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" diff --git a/crates/mesh-client/Cargo.toml b/crates/mesh-client/Cargo.toml index fd7c123667..8fb26afa0a 100644 --- a/crates/mesh-client/Cargo.toml +++ b/crates/mesh-client/Cargo.toml @@ -23,11 +23,11 @@ host-io = ["mesh-llm-identity/host-io"] # tracing, sha2, ed25519-dalek, hex, uuid, url, http, base64, async-trait, httparse # (httparse is a transitive dep of iroh; not in forbidden list) iroh = { version = "1.0.3", default-features = false, features = ["metrics", "fast-apple-datapath", "portmapper", "tls-aws-lc-rs"] } -mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.76.0-rc9", default-features = false } -mesh-llm-protocol = { path = "../mesh-llm-protocol", version = "0.76.0-rc9" } -mesh-llm-routing = { path = "../mesh-llm-routing", version = "0.76.0-rc9" } -mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0-rc9" } -model-artifact = { path = "../model-artifact", version = "0.76.0-rc9" } +mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.76.0", default-features = false } +mesh-llm-protocol = { path = "../mesh-llm-protocol", version = "0.76.0" } +mesh-llm-routing = { path = "../mesh-llm-routing", version = "0.76.0" } +mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0" } +model-artifact = { path = "../model-artifact", version = "0.76.0" } async-trait = "0.1" httparse = "1" tokio = { version = "1", features = ["io-util", "sync", "net", "time", "rt-multi-thread"] } diff --git a/crates/mesh-llm-api-client/Cargo.toml b/crates/mesh-llm-api-client/Cargo.toml index 184935105d..940b6db07d 100644 --- a/crates/mesh-llm-api-client/Cargo.toml +++ b/crates/mesh-llm-api-client/Cargo.toml @@ -14,7 +14,7 @@ categories = ["api-bindings", "network-programming"] host-io = ["mesh-client/host-io"] [dependencies] -mesh-client = { package = "mesh-llm-client", version = "0.76.0-rc9", path = "../mesh-client" } +mesh-client = { package = "mesh-llm-client", version = "0.76.0", path = "../mesh-client" } hex = "0.4" thiserror = "2" diff --git a/crates/mesh-llm-api-server/Cargo.toml b/crates/mesh-llm-api-server/Cargo.toml index 3f278783ff..6d969d2395 100644 --- a/crates/mesh-llm-api-server/Cargo.toml +++ b/crates/mesh-llm-api-server/Cargo.toml @@ -15,8 +15,8 @@ host-io = ["mesh-llm-api-client/host-io"] [dependencies] anyhow.workspace = true -mesh-llm-api-client = { path = "../mesh-llm-api-client", version = "0.76.0-rc9" } -mesh-llm-node = { path = "../mesh-llm-node", version = "0.76.0-rc9" } +mesh-llm-api-client = { path = "../mesh-llm-api-client", version = "0.76.0" } +mesh-llm-node = { path = "../mesh-llm-node", version = "0.76.0" } tokio = { version = "1", features = ["sync"] } [dev-dependencies] diff --git a/crates/mesh-llm-cli/Cargo.toml b/crates/mesh-llm-cli/Cargo.toml index fea67a77ae..f587cdb132 100644 --- a/crates/mesh-llm-cli/Cargo.toml +++ b/crates/mesh-llm-cli/Cargo.toml @@ -17,6 +17,6 @@ workspace = true anyhow.workspace = true clap.workspace = true mesh-llm-build-info.workspace = true -mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0-rc9" } +mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0" } serde.workspace = true serde_json.workspace = true diff --git a/crates/mesh-llm-commands/Cargo.toml b/crates/mesh-llm-commands/Cargo.toml index 9b02faefae..931b9776a1 100644 --- a/crates/mesh-llm-commands/Cargo.toml +++ b/crates/mesh-llm-commands/Cargo.toml @@ -24,19 +24,19 @@ iroh = { version = "1.0.3", default-features = false, features = ["metrics", "fa json5 = "1.3.1" nix = { version = "0.31", default-features = false, features = ["signal"] } mesh-llm-build-info.workspace = true -mesh-llm-cli = { path = "../mesh-llm-cli", version = "0.76.0-rc9" } -mesh-llm-config = { path = "../mesh-llm-config", version = "0.76.0-rc9" } -mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0-rc9" } -mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0-rc9" } -mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.76.0-rc9" } -mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.76.0-rc9", features = ["host-io"] } -mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0-rc9" } -mesh-llm-system = { path = "../mesh-llm-system", version = "0.76.0-rc9", features = ["skippy-devices"] } -mesh-llm-tui = { path = "../mesh-llm-tui", version = "0.76.0-rc9" } -model-artifact = { path = "../model-artifact", version = "0.76.0-rc9" } -model-hf = { path = "../model-hf", version = "0.76.0-rc9" } -model-package = { path = "../model-package", version = "0.76.0-rc9" } -model-ref = { path = "../model-ref", version = "0.76.0-rc9" } +mesh-llm-cli = { path = "../mesh-llm-cli", version = "0.76.0" } +mesh-llm-config = { path = "../mesh-llm-config", version = "0.76.0" } +mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0" } +mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0" } +mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.76.0" } +mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.76.0", features = ["host-io"] } +mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0" } +mesh-llm-system = { path = "../mesh-llm-system", version = "0.76.0", features = ["skippy-devices"] } +mesh-llm-tui = { path = "../mesh-llm-tui", version = "0.76.0" } +model-artifact = { path = "../model-artifact", version = "0.76.0" } +model-hf = { path = "../model-hf", version = "0.76.0" } +model-package = { path = "../model-package", version = "0.76.0" } +model-ref = { path = "../model-ref", version = "0.76.0" } rpassword = "7.5" reqwest = { version = "0.13", features = ["blocking", "json"] } serde.workspace = true diff --git a/crates/mesh-llm-config/Cargo.toml b/crates/mesh-llm-config/Cargo.toml index 347f54130d..7ab666bb0a 100644 --- a/crates/mesh-llm-config/Cargo.toml +++ b/crates/mesh-llm-config/Cargo.toml @@ -12,10 +12,10 @@ workspace = true [dependencies] anyhow = { workspace = true } -mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0-rc9" } +mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0" } semver = "1" serde = { workspace = true } -skippy-protocol = { path = "../skippy-protocol", version = "0.76.0-rc9" } +skippy-protocol = { path = "../skippy-protocol", version = "0.76.0" } toml = "1.1" toml_edit = "0.25" dirs = "6.0.0" diff --git a/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs b/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs index 74935e1de4..da2cc43c74 100644 --- a/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs +++ b/crates/mesh-llm-config/src/model/built_in_schema/presentation.rs @@ -263,7 +263,7 @@ fn process_setting_presentation(rendered: &str) -> Option { ATTESTATION_CATEGORY, 20, ) - .placeholder("0.76.0-rc9") + .placeholder("0.76.0") .hint("text")), "mesh_requirements.min_protocol_version" => Some(sp( "Minimum protocol generation", diff --git a/crates/mesh-llm-config/src/model/built_in_schema/setting_schema.rs b/crates/mesh-llm-config/src/model/built_in_schema/setting_schema.rs index a52dd3cff9..9aa4207639 100644 --- a/crates/mesh-llm-config/src/model/built_in_schema/setting_schema.rs +++ b/crates/mesh-llm-config/src/model/built_in_schema/setting_schema.rs @@ -336,6 +336,7 @@ fn tensor_split_schema() -> ConfigValueSchema { /// This list should be updated during the release process. fn known_mesh_llm_versions() -> &'static [&'static str] { &[ + "0.76.0", "0.76.0-rc9", "0.76.0-rc8", "0.76.0-rc7", diff --git a/crates/mesh-llm-console-server/Cargo.toml b/crates/mesh-llm-console-server/Cargo.toml index 777c435d05..edf682f3e7 100644 --- a/crates/mesh-llm-console-server/Cargo.toml +++ b/crates/mesh-llm-console-server/Cargo.toml @@ -13,5 +13,5 @@ workspace = true [dependencies] anyhow.workspace = true -mesh-llm-ui = { path = "../mesh-llm-ui", version = "0.76.0-rc9", default-features = false } +mesh-llm-ui = { path = "../mesh-llm-ui", version = "0.76.0", default-features = false } tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] } diff --git a/crates/mesh-llm-embedded-runtime/Cargo.toml b/crates/mesh-llm-embedded-runtime/Cargo.toml index e74e13a18b..f691a89e3c 100644 --- a/crates/mesh-llm-embedded-runtime/Cargo.toml +++ b/crates/mesh-llm-embedded-runtime/Cargo.toml @@ -20,5 +20,5 @@ workspace = true [dependencies] anyhow.workspace = true -mesh-llm-host-runtime = { path = "../mesh-llm-host-runtime", version = "0.76.0-rc9", default-features = false } +mesh-llm-host-runtime = { path = "../mesh-llm-host-runtime", version = "0.76.0", default-features = false } serde_json.workspace = true diff --git a/crates/mesh-llm-hardware-profile/Cargo.toml b/crates/mesh-llm-hardware-profile/Cargo.toml index 93c2989e2a..91a8d302ab 100644 --- a/crates/mesh-llm-hardware-profile/Cargo.toml +++ b/crates/mesh-llm-hardware-profile/Cargo.toml @@ -12,4 +12,4 @@ readme = "README.md" workspace = true [dependencies] -mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0-rc9" } +mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0" } diff --git a/crates/mesh-llm-host-runtime/Cargo.toml b/crates/mesh-llm-host-runtime/Cargo.toml index f231f2d7dc..711e837a8f 100644 --- a/crates/mesh-llm-host-runtime/Cargo.toml +++ b/crates/mesh-llm-host-runtime/Cargo.toml @@ -26,39 +26,39 @@ workspace = true [dependencies] bytes = "1" mesh-llm-build-info.workspace = true -mesh-mixture-of-agents = { path = "../mesh-mixture-of-agents", version = "0.76.0-rc9" } -mesh-llm-config = { path = "../mesh-llm-config", version = "0.76.0-rc9" } -mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0-rc9" } -mesh-llm-log-store = { path = "../mesh-llm-log-store", version = "0.76.0-rc9" } -mesh-llm-plugin = { path = "../mesh-llm-plugin", version = "0.76.0-rc9" } -mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.76.0-rc9" } -mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.76.0-rc9", features = ["host-io"] } -mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0-rc9" } -mesh-native-serving-plugin-host = { path = "../mesh-native-serving-plugin-host", version = "0.76.0-rc9" } -mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0-rc9" } -mesh-llm-guardrails = { path = "../mesh-llm-guardrails", version = "0.76.0-rc9" } -mesh-llm-protocol = { path = "../mesh-llm-protocol", version = "0.76.0-rc9" } -mesh-llm-routing = { path = "../mesh-llm-routing", version = "0.76.0-rc9" } -mesh-llm-system = { path = "../mesh-llm-system", version = "0.76.0-rc9", features = ["skippy-devices"] } -mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0-rc9" } -mesh-llm-ui = { path = "../mesh-llm-ui", version = "0.76.0-rc9", default-features = false } -mesh-llm-node = { path = "../mesh-llm-node", version = "0.76.0-rc9" } -mesh-llm-api-server = { path = "../mesh-llm-api-server", version = "0.76.0-rc9" } -mesh-client = { package = "mesh-llm-client", path = "../mesh-client", version = "0.76.0-rc9", features = ["host-io"] } -model-artifact = { path = "../model-artifact", version = "0.76.0-rc9" } -model-hf = { path = "../model-hf", version = "0.76.0-rc9" } -model-package = { path = "../model-package", version = "0.76.0-rc9" } -model-ref = { path = "../model-ref", version = "0.76.0-rc9" } -model-resolver = { path = "../model-resolver", version = "0.76.0-rc9" } -openai-frontend = { path = "../openai-frontend", version = "0.76.0-rc9" } -skippy-protocol = { path = "../skippy-protocol", version = "0.76.0-rc9" } -skippy-coordinator = { path = "../skippy-coordinator", version = "0.76.0-rc9" } -skippy-runtime = { path = "../skippy-runtime", version = "0.76.0-rc9" } -skippy-ffi = { path = "../skippy-ffi", version = "0.76.0-rc9", default-features = false } -skippy-model = { path = "../skippy-model", version = "0.76.0-rc9" } -skippy-server = { path = "../skippy-server", version = "0.76.0-rc9" } -skippy-topology = { path = "../skippy-topology", version = "0.76.0-rc9" } -skippy-package-format = { path = "../skippy-package-format", version = "0.76.0-rc9" } +mesh-mixture-of-agents = { path = "../mesh-mixture-of-agents", version = "0.76.0" } +mesh-llm-config = { path = "../mesh-llm-config", version = "0.76.0" } +mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0" } +mesh-llm-log-store = { path = "../mesh-llm-log-store", version = "0.76.0" } +mesh-llm-plugin = { path = "../mesh-llm-plugin", version = "0.76.0" } +mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.76.0" } +mesh-llm-identity = { path = "../mesh-llm-identity", version = "0.76.0", features = ["host-io"] } +mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0" } +mesh-native-serving-plugin-host = { path = "../mesh-native-serving-plugin-host", version = "0.76.0" } +mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0" } +mesh-llm-guardrails = { path = "../mesh-llm-guardrails", version = "0.76.0" } +mesh-llm-protocol = { path = "../mesh-llm-protocol", version = "0.76.0" } +mesh-llm-routing = { path = "../mesh-llm-routing", version = "0.76.0" } +mesh-llm-system = { path = "../mesh-llm-system", version = "0.76.0", features = ["skippy-devices"] } +mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0" } +mesh-llm-ui = { path = "../mesh-llm-ui", version = "0.76.0", default-features = false } +mesh-llm-node = { path = "../mesh-llm-node", version = "0.76.0" } +mesh-llm-api-server = { path = "../mesh-llm-api-server", version = "0.76.0" } +mesh-client = { package = "mesh-llm-client", path = "../mesh-client", version = "0.76.0", features = ["host-io"] } +model-artifact = { path = "../model-artifact", version = "0.76.0" } +model-hf = { path = "../model-hf", version = "0.76.0" } +model-package = { path = "../model-package", version = "0.76.0" } +model-ref = { path = "../model-ref", version = "0.76.0" } +model-resolver = { path = "../model-resolver", version = "0.76.0" } +openai-frontend = { path = "../openai-frontend", version = "0.76.0" } +skippy-protocol = { path = "../skippy-protocol", version = "0.76.0" } +skippy-coordinator = { path = "../skippy-coordinator", version = "0.76.0" } +skippy-runtime = { path = "../skippy-runtime", version = "0.76.0" } +skippy-ffi = { path = "../skippy-ffi", version = "0.76.0", default-features = false } +skippy-model = { path = "../skippy-model", version = "0.76.0" } +skippy-server = { path = "../skippy-server", version = "0.76.0" } +skippy-topology = { path = "../skippy-topology", version = "0.76.0" } +skippy-package-format = { path = "../skippy-package-format", version = "0.76.0" } iroh = { version = "1.0.3", default-features = false, features = ["metrics", "fast-apple-datapath", "portmapper", "tls-aws-lc-rs"] } tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } @@ -125,7 +125,7 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] serial_test = "4" opentelemetry_sdk = { version = "0.32.1", default-features = false, features = ["metrics", "testing"] } -mesh-client = { package = "mesh-llm-client", path = "../mesh-client", version = "0.76.0-rc9" } +mesh-client = { package = "mesh-llm-client", path = "../mesh-client", version = "0.76.0" } # Used by the gated-relay regression test to spawn an in-process iroh-relay # with AccessConfig::Restricted, then build a real iroh::Endpoint from our # relay_map_from_urls output and verify --relay-auth tokens reach the relay diff --git a/crates/mesh-llm-host-runtime/tests/fixtures/config_schema_reference.json b/crates/mesh-llm-host-runtime/tests/fixtures/config_schema_reference.json index 55cc4d14d4..a0cdba444a 100644 --- a/crates/mesh-llm-host-runtime/tests/fixtures/config_schema_reference.json +++ b/crates/mesh-llm-host-runtime/tests/fixtures/config_schema_reference.json @@ -937,7 +937,7 @@ "name": "blobstore", "enabled": true, "source_repository": "built-in", - "installed_version": "0.76.0-rc9", + "installed_version": "0.76.0", "last_status": "built-in", "has_config_schema": false, "allow_unvalidated_config": false diff --git a/crates/mesh-llm-log-store/Cargo.toml b/crates/mesh-llm-log-store/Cargo.toml index 21606b6752..c57367e9ca 100644 --- a/crates/mesh-llm-log-store/Cargo.toml +++ b/crates/mesh-llm-log-store/Cargo.toml @@ -17,7 +17,7 @@ workspace = true chrono = { version = "0.4", default-features = false, features = ["clock"] } data-encoding = "2.6" hex = "0.4" -mesh-llm-events = { version = "0.76.0-rc9", path = "../mesh-llm-events" } +mesh-llm-events = { version = "0.76.0", path = "../mesh-llm-events" } rusqlite = { version = "0.40", default-features = false, features = ["bundled", "fallible_uint", "functions"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/mesh-llm-native-runtime/README.md b/crates/mesh-llm-native-runtime/README.md index aa17aedfb4..49c4b17810 100644 --- a/crates/mesh-llm-native-runtime/README.md +++ b/crates/mesh-llm-native-runtime/README.md @@ -32,7 +32,7 @@ Each packaged runtime directory contains `manifest.json`: { "runtime": { "id": "meshllm-native-runtime-linux-x86_64-cuda13-sm120", - "mesh_version": "0.76.0-rc9", + "mesh_version": "0.76.0", "skippy_abi": "0.1.25", "platform": { "os": "linux", @@ -87,18 +87,18 @@ Release jobs publish `native-runtimes.json`: ```json { - "mesh_version": "0.76.0-rc9", + "mesh_version": "0.76.0", "skippy_abi": "0.1.25", "artifacts": [ { "id": "meshllm-native-runtime-linux-x86_64-cpu", - "mesh_version": "0.76.0-rc9", + "mesh_version": "0.76.0", "skippy_abi": "0.1.25", "platform": { "os": "linux", "arch": "x86_64" }, "backend": { "kind": "cpu" }, "rank": 0, "libraries": ["lib/libllama.so"], - "url": "https://github.com/Mesh-LLM/mesh-llm/releases/download/v0.76.0-rc9/meshllm-native-runtime-linux-x86_64-cpu.tar.gz", + "url": "https://github.com/Mesh-LLM/mesh-llm/releases/download/v0.76.0/meshllm-native-runtime-linux-x86_64-cpu.tar.gz", "sha256": "2f1c..." } ] @@ -176,7 +176,7 @@ use std::path::PathBuf; # manifest: NativeRuntimeReleaseManifest, # ) -> anyhow::Result<()> { let cache = NativeRuntimeCache::new("/tmp/mesh-llm/native-runtimes"); -let resolution = NativeRuntimeResolver::new("0.76.0-rc9", profile, manifest, cache) +let resolution = NativeRuntimeResolver::new("0.76.0", profile, manifest, cache) .with_skippy_abi_version("0.1.25") .with_bundle_dirs(vec![PathBuf::from("./meshllm-native-runtime-linux-x86_64-cpu")]) .resolve(&RuntimeSelection::Recommended)?; @@ -271,7 +271,7 @@ Generate the release manifest: ```bash scripts/generate-native-runtime-release-manifest.sh \ - --tag v0.76.0-rc9 \ + --tag v0.76.0 \ --out dist/native-runtimes/native-runtimes.json \ dist/native-runtimes/*.tar.gz ``` diff --git a/crates/mesh-llm-node/Cargo.toml b/crates/mesh-llm-node/Cargo.toml index 2a5e07118b..040cb8eaae 100644 --- a/crates/mesh-llm-node/Cargo.toml +++ b/crates/mesh-llm-node/Cargo.toml @@ -13,10 +13,10 @@ host = [] [dependencies] anyhow.workspace = true -mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0-rc9" } -model-artifact = { path = "../model-artifact", version = "0.76.0-rc9" } -model-hf = { path = "../model-hf", version = "0.76.0-rc9" } -model-ref = { path = "../model-ref", version = "0.76.0-rc9" } +mesh-llm-types = { path = "../mesh-llm-types", version = "0.76.0" } +model-artifact = { path = "../model-artifact", version = "0.76.0" } +model-hf = { path = "../model-hf", version = "0.76.0" } +model-ref = { path = "../model-ref", version = "0.76.0" } serde.workspace = true serde_json.workspace = true diff --git a/crates/mesh-llm-nodejs/Cargo.toml b/crates/mesh-llm-nodejs/Cargo.toml index 4d593e1aa2..c066a65b90 100644 --- a/crates/mesh-llm-nodejs/Cargo.toml +++ b/crates/mesh-llm-nodejs/Cargo.toml @@ -16,7 +16,7 @@ default = ["embedded-runtime"] embedded-runtime = [] [dependencies] -mesh-llm-sdk = { path = "../mesh-llm-sdk", version = "0.76.0-rc9", default-features = false, features = ["client", "node", "console", "serving"] } +mesh-llm-sdk = { path = "../mesh-llm-sdk", version = "0.76.0", default-features = false, features = ["client", "node", "console", "serving"] } napi = { version = "3.12.2", features = ["napi4", "tokio_rt"] } napi-derive = "3.6.3" serde_json.workspace = true diff --git a/crates/mesh-llm-runtime-install/Cargo.toml b/crates/mesh-llm-runtime-install/Cargo.toml index 78600d8c63..1117b502fc 100644 --- a/crates/mesh-llm-runtime-install/Cargo.toml +++ b/crates/mesh-llm-runtime-install/Cargo.toml @@ -18,13 +18,13 @@ flate2 = "1" futures-util = "0.3" hex = "0.4" mesh-llm-build-info.workspace = true -mesh-llm-hardware-profile = { path = "../mesh-llm-hardware-profile", version = "0.76.0-rc9" } -mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0-rc9" } +mesh-llm-hardware-profile = { path = "../mesh-llm-hardware-profile", version = "0.76.0" } +mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0" } reqwest = { version = "0.13", features = ["stream", "json"] } serde.workspace = true serde_json.workspace = true sha2.workspace = true -skippy-ffi = { path = "../skippy-ffi", version = "0.76.0-rc9", default-features = false } +skippy-ffi = { path = "../skippy-ffi", version = "0.76.0", default-features = false } tar = "0.4" tempfile = "3" tokio = { version = "1", features = ["fs", "io-util", "rt"] } diff --git a/crates/mesh-llm-sdk/Cargo.toml b/crates/mesh-llm-sdk/Cargo.toml index f965f539da..818af70ceb 100644 --- a/crates/mesh-llm-sdk/Cargo.toml +++ b/crates/mesh-llm-sdk/Cargo.toml @@ -31,11 +31,11 @@ workspace = true [dependencies] anyhow = { workspace = true, optional = true } -mesh-llm-api-client = { path = "../mesh-llm-api-client", version = "0.76.0-rc9", optional = true } -mesh-llm-api-server = { path = "../mesh-llm-api-server", version = "0.76.0-rc9", optional = true } -mesh-llm-console-server = { path = "../mesh-llm-console-server", version = "0.76.0-rc9", optional = true } -mesh-llm-embedded-runtime = { path = "../mesh-llm-embedded-runtime", version = "0.76.0-rc9", optional = true } -mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0-rc9", optional = true } +mesh-llm-api-client = { path = "../mesh-llm-api-client", version = "0.76.0", optional = true } +mesh-llm-api-server = { path = "../mesh-llm-api-server", version = "0.76.0", optional = true } +mesh-llm-console-server = { path = "../mesh-llm-console-server", version = "0.76.0", optional = true } +mesh-llm-embedded-runtime = { path = "../mesh-llm-embedded-runtime", version = "0.76.0", optional = true } +mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0", optional = true } reqwest = { version = "0.13", features = ["json"], optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } diff --git a/crates/mesh-llm-sdk/README.md b/crates/mesh-llm-sdk/README.md index c7d7655d17..9c3061aa98 100644 --- a/crates/mesh-llm-sdk/README.md +++ b/crates/mesh-llm-sdk/README.md @@ -23,7 +23,7 @@ checked against the exact Skippy ABI version. ```toml [dependencies] -mesh-llm-sdk = "0.76.0-rc9" +mesh-llm-sdk = "0.76.0" ``` ```rust,no_run @@ -45,7 +45,7 @@ client.disconnect().await; ```toml [dependencies] -mesh-llm-sdk = { version = "0.76.0-rc9", features = ["serving"] } +mesh-llm-sdk = { version = "0.76.0", features = ["serving"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } anyhow = "1" ``` @@ -80,7 +80,7 @@ Enable `serving` to use native-runtime cache and install APIs: ```toml [dependencies] -mesh-llm-sdk = { version = "0.76.0-rc9", features = ["serving"] } +mesh-llm-sdk = { version = "0.76.0", features = ["serving"] } ``` ```rust,no_run diff --git a/crates/mesh-llm-system/Cargo.toml b/crates/mesh-llm-system/Cargo.toml index b7e7bfc614..78a406996c 100644 --- a/crates/mesh-llm-system/Cargo.toml +++ b/crates/mesh-llm-system/Cargo.toml @@ -16,16 +16,16 @@ hex = "0.4.3" libc = "0.2.183" libloading = "0.9" mesh-llm-build-info.workspace = true -mesh-llm-gpu-bench = { path = "../mesh-llm-gpu-bench", version = "0.76.0-rc9" } -mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0-rc9" } +mesh-llm-gpu-bench = { path = "../mesh-llm-gpu-bench", version = "0.76.0" } +mesh-llm-native-runtime = { path = "../mesh-llm-native-runtime", version = "0.76.0" } mesh-llm-release-footer.workspace = true -mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0-rc9" } +mesh-llm-runtime-install = { path = "../mesh-llm-runtime-install", version = "0.76.0" } reqwest = { version = "0.13", features = ["stream", "json", "query"] } semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" -skippy-runtime = { path = "../skippy-runtime", version = "0.76.0-rc9", optional = true } +skippy-runtime = { path = "../skippy-runtime", version = "0.76.0", optional = true } tracing = "0.1" zip = { version = "8.6", default-features = false, features = ["deflate"] } diff --git a/crates/mesh-llm-tui/Cargo.toml b/crates/mesh-llm-tui/Cargo.toml index 04494d2ffb..ca283886eb 100644 --- a/crates/mesh-llm-tui/Cargo.toml +++ b/crates/mesh-llm-tui/Cargo.toml @@ -19,7 +19,7 @@ anyhow.workspace = true arboard = "3" chrono = { version = "0.4", features = ["serde"] } crossterm = "0.29" -mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0-rc9" } +mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0" } ratatui = "0.30" serde_json.workspace = true tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } diff --git a/crates/mesh-llm-ui/package-lock.json b/crates/mesh-llm-ui/package-lock.json index 8be14c5453..9710d840c7 100644 --- a/crates/mesh-llm-ui/package-lock.json +++ b/crates/mesh-llm-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "mesh-llm-ui", - "version": "0.76.0-rc9", + "version": "0.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mesh-llm-ui", - "version": "0.76.0-rc9", + "version": "0.76.0", "dependencies": { "@huggingface/transformers": "^4.2.0", "@radix-ui/react-accordion": "^1.2.20", diff --git a/crates/mesh-llm-ui/package.json b/crates/mesh-llm-ui/package.json index f2971bd90d..dc771ce767 100644 --- a/crates/mesh-llm-ui/package.json +++ b/crates/mesh-llm-ui/package.json @@ -1,7 +1,7 @@ { "name": "mesh-llm-ui", "private": true, - "version": "0.76.0-rc9", + "version": "0.76.0", "type": "module", "packageManager": "pnpm@10.34.5", "engines": { diff --git a/crates/mesh-llm/Cargo.toml b/crates/mesh-llm/Cargo.toml index 069d928ce2..97b8c782ed 100644 --- a/crates/mesh-llm/Cargo.toml +++ b/crates/mesh-llm/Cargo.toml @@ -18,14 +18,14 @@ workspace = true anyhow.workspace = true chrono = { version = "0.4", features = ["serde"] } clap.workspace = true -mesh-llm-cli = { path = "../mesh-llm-cli", version = "0.76.0-rc9" } -mesh-llm-commands = { path = "../mesh-llm-commands", version = "0.76.0-rc9" } -mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0-rc9" } -mesh-llm-host-runtime = { path = "../mesh-llm-host-runtime", version = "0.76.0-rc9", default-features = false } -mesh-llm-plugin = { path = "../mesh-llm-plugin", version = "0.76.0-rc9" } -mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.76.0-rc9" } -mesh-llm-system = { path = "../mesh-llm-system", version = "0.76.0-rc9", features = ["skippy-devices"] } -mesh-llm-tui = { path = "../mesh-llm-tui", version = "0.76.0-rc9" } +mesh-llm-cli = { path = "../mesh-llm-cli", version = "0.76.0" } +mesh-llm-commands = { path = "../mesh-llm-commands", version = "0.76.0" } +mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0" } +mesh-llm-host-runtime = { path = "../mesh-llm-host-runtime", version = "0.76.0", default-features = false } +mesh-llm-plugin = { path = "../mesh-llm-plugin", version = "0.76.0" } +mesh-llm-plugin-manager = { path = "../mesh-llm-plugin-manager", version = "0.76.0" } +mesh-llm-system = { path = "../mesh-llm-system", version = "0.76.0", features = ["skippy-devices"] } +mesh-llm-tui = { path = "../mesh-llm-tui", version = "0.76.0" } reqwest = { version = "0.13", features = ["json"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/mesh-mixture-of-agents/Cargo.toml b/crates/mesh-mixture-of-agents/Cargo.toml index 25aa3de0f5..9c6fe97722 100644 --- a/crates/mesh-mixture-of-agents/Cargo.toml +++ b/crates/mesh-mixture-of-agents/Cargo.toml @@ -10,7 +10,7 @@ readme = "README.md" [dependencies] async-trait = "0.1" -mesh-llm-guardrails = { path = "../mesh-llm-guardrails", version = "0.76.0-rc9" } +mesh-llm-guardrails = { path = "../mesh-llm-guardrails", version = "0.76.0" } reqwest = { version = "0.13", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/mesh-native-serving-plugin-host/Cargo.toml b/crates/mesh-native-serving-plugin-host/Cargo.toml index 7c6753a261..5c30d085ef 100644 --- a/crates/mesh-native-serving-plugin-host/Cargo.toml +++ b/crates/mesh-native-serving-plugin-host/Cargo.toml @@ -11,12 +11,12 @@ readme = "README.md" [dependencies] anyhow.workspace = true libloading = "0.9" -mesh-native-serving-plugin-api = { path = "../mesh-native-serving-plugin-api", version = "0.76.0-rc9" } -skippy-server = { path = "../skippy-server", version = "0.76.0-rc9" } -skippy-tokenizer = { path = "../skippy-tokenizer", version = "0.76.0-rc9" } +mesh-native-serving-plugin-api = { path = "../mesh-native-serving-plugin-api", version = "0.76.0" } +skippy-server = { path = "../skippy-server", version = "0.76.0" } +skippy-tokenizer = { path = "../skippy-tokenizer", version = "0.76.0" } [lints] workspace = true [dev-dependencies] -skippy-server = { path = "../skippy-server", version = "0.76.0-rc9", features = ["test-support"] } +skippy-server = { path = "../skippy-server", version = "0.76.0", features = ["test-support"] } diff --git a/crates/model-artifact/Cargo.toml b/crates/model-artifact/Cargo.toml index 9bbe29176f..5774c17c17 100644 --- a/crates/model-artifact/Cargo.toml +++ b/crates/model-artifact/Cargo.toml @@ -11,7 +11,7 @@ readme = "README.md" [dependencies] anyhow.workspace = true async-trait = "0.1" -model-ref = { path = "../model-ref", version = "0.76.0-rc9" } +model-ref = { path = "../model-ref", version = "0.76.0" } serde.workspace = true [dev-dependencies] diff --git a/crates/model-hf/Cargo.toml b/crates/model-hf/Cargo.toml index 480926d2e8..28a87b9097 100644 --- a/crates/model-hf/Cargo.toml +++ b/crates/model-hf/Cargo.toml @@ -15,8 +15,8 @@ chrono = { version = "0.4", features = ["serde"] } dirs = "6.0.0" hf_hub = { package = "mesh-llm-hf-hub", version = "1.0.2", default-features = false, features = ["blocking"] } libc = "0.2" -model-artifact = { path = "../model-artifact", version = "0.76.0-rc9" } -model-ref = { path = "../model-ref", version = "0.76.0-rc9" } +model-artifact = { path = "../model-artifact", version = "0.76.0" } +model-ref = { path = "../model-ref", version = "0.76.0" } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "ring", "std", "tls12", "logging"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/model-package/Cargo.toml b/crates/model-package/Cargo.toml index 5b2dffd58d..974fe3ba19 100644 --- a/crates/model-package/Cargo.toml +++ b/crates/model-package/Cargo.toml @@ -16,8 +16,8 @@ anyhow.workspace = true bytes = "1" chrono = "0.4" hf_hub = { package = "mesh-llm-hf-hub", version = "1.0.2", default-features = false, features = ["blocking"] } -model-hf = { path = "../model-hf", version = "0.76.0-rc9" } -model-ref = { path = "../model-ref", version = "0.76.0-rc9" } +model-hf = { path = "../model-hf", version = "0.76.0" } +model-ref = { path = "../model-ref", version = "0.76.0" } reqwest = { version = "0.13", features = ["json", "stream"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/model-resolver/Cargo.toml b/crates/model-resolver/Cargo.toml index f1f0793406..fe25768dce 100644 --- a/crates/model-resolver/Cargo.toml +++ b/crates/model-resolver/Cargo.toml @@ -10,8 +10,8 @@ readme = "README.md" [dependencies] anyhow.workspace = true -model-ref = { path = "../model-ref", version = "0.76.0-rc9" } -model-artifact = { path = "../model-artifact", version = "0.76.0-rc9" } +model-ref = { path = "../model-ref", version = "0.76.0" } +model-artifact = { path = "../model-artifact", version = "0.76.0" } serde.workspace = true serde_json.workspace = true diff --git a/crates/openai-frontend/Cargo.toml b/crates/openai-frontend/Cargo.toml index a11f66f1cf..ebc2bc3c15 100644 --- a/crates/openai-frontend/Cargo.toml +++ b/crates/openai-frontend/Cargo.toml @@ -12,8 +12,8 @@ async-trait = "0.1" axum = "0.8" futures-core = "0.3" futures-util = "0.3" -mesh-llm-guardrails = { path = "../mesh-llm-guardrails", version = "0.76.0-rc9" } -mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0-rc9" } +mesh-llm-guardrails = { path = "../mesh-llm-guardrails", version = "0.76.0" } +mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0" } serde.workspace = true serde_json.workspace = true tokio = { version = "1", features = ["rt", "time"] } diff --git a/crates/skippy-cache/Cargo.toml b/crates/skippy-cache/Cargo.toml index 2b89f8b62e..2043bbf3a6 100644 --- a/crates/skippy-cache/Cargo.toml +++ b/crates/skippy-cache/Cargo.toml @@ -14,4 +14,4 @@ path = "src/lib.rs" [dependencies] anyhow.workspace = true blake3.workspace = true -skippy-protocol = { path = "../skippy-protocol", version = "0.76.0-rc9" } +skippy-protocol = { path = "../skippy-protocol", version = "0.76.0" } diff --git a/crates/skippy-model/Cargo.toml b/crates/skippy-model/Cargo.toml index 8c9712e8b8..61ef346e52 100644 --- a/crates/skippy-model/Cargo.toml +++ b/crates/skippy-model/Cargo.toml @@ -15,4 +15,4 @@ memmap2 = "0.9.11" safetensors = "0.8.0" serde.workspace = true serde_json.workspace = true -skippy-package-format = { path = "../skippy-package-format", version = "0.76.0-rc9" } +skippy-package-format = { path = "../skippy-package-format", version = "0.76.0" } diff --git a/crates/skippy-protocol/Cargo.toml b/crates/skippy-protocol/Cargo.toml index 97460e922d..f6ca4ff25b 100644 --- a/crates/skippy-protocol/Cargo.toml +++ b/crates/skippy-protocol/Cargo.toml @@ -10,7 +10,7 @@ homepage = "https://github.com/Mesh-LLM/mesh-llm" [dependencies] prost = "0.14" serde.workspace = true -skippy-tokenizer = { path = "../skippy-tokenizer", version = "0.76.0-rc9" } +skippy-tokenizer = { path = "../skippy-tokenizer", version = "0.76.0" } [dev-dependencies] serde_json.workspace = true diff --git a/crates/skippy-runtime/Cargo.toml b/crates/skippy-runtime/Cargo.toml index 16e9d51765..bcc9e9e0c1 100644 --- a/crates/skippy-runtime/Cargo.toml +++ b/crates/skippy-runtime/Cargo.toml @@ -13,8 +13,8 @@ dynamic-native-runtime = ["skippy-ffi/dynamic-runtime"] [dependencies] anyhow.workspace = true -skippy-ffi = { path = "../skippy-ffi", version = "0.76.0-rc9", default-features = false } -skippy-model = { path = "../skippy-model", version = "0.76.0-rc9" } +skippy-ffi = { path = "../skippy-ffi", version = "0.76.0", default-features = false } +skippy-model = { path = "../skippy-model", version = "0.76.0" } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/skippy-scheduler/Cargo.toml b/crates/skippy-scheduler/Cargo.toml index f6bc4bb2ac..fc9ce96880 100644 --- a/crates/skippy-scheduler/Cargo.toml +++ b/crates/skippy-scheduler/Cargo.toml @@ -12,13 +12,13 @@ categories = ["concurrency", "artificial-intelligence"] scheduler-lab = ["skippy-runtime/dynamic-native-runtime"] [dependencies] -skippy-runtime = { path = "../skippy-runtime", version = "0.76.0-rc9" } +skippy-runtime = { path = "../skippy-runtime", version = "0.76.0" } thiserror = "2" [dev-dependencies] serde = { workspace = true } serde_json = { workspace = true } -skippy-cache = { path = "../skippy-cache", version = "0.76.0-rc9" } +skippy-cache = { path = "../skippy-cache", version = "0.76.0" } [[bench]] name = "scheduler_lab" diff --git a/crates/skippy-server/Cargo.toml b/crates/skippy-server/Cargo.toml index 9f99340a3c..e96c73df98 100644 --- a/crates/skippy-server/Cargo.toml +++ b/crates/skippy-server/Cargo.toml @@ -25,17 +25,17 @@ base64 = "0.23" blake3.workspace = true clap.workspace = true futures-util = "0.3" -skippy-runtime = { path = "../skippy-runtime", version = "0.76.0-rc9" } -skippy-scheduler = { path = "../skippy-scheduler", version = "0.76.0-rc9" } -skippy-tokenizer = { path = "../skippy-tokenizer", version = "0.76.0-rc9" } -model-artifact = { path = "../model-artifact", version = "0.76.0-rc9" } -mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0-rc9" } -mesh-native-serving-plugin-api = { path = "../mesh-native-serving-plugin-api", version = "0.76.0-rc9" } -skippy-protocol = { path = "../skippy-protocol", version = "0.76.0-rc9" } -skippy-cache = { path = "../skippy-cache", version = "0.76.0-rc9" } -skippy-metrics = { path = "../skippy-metrics", version = "0.76.0-rc9" } -skippy-topology = { path = "../skippy-topology", version = "0.76.0-rc9" } -openai-frontend = { path = "../openai-frontend", version = "0.76.0-rc9" } +skippy-runtime = { path = "../skippy-runtime", version = "0.76.0" } +skippy-scheduler = { path = "../skippy-scheduler", version = "0.76.0" } +skippy-tokenizer = { path = "../skippy-tokenizer", version = "0.76.0" } +model-artifact = { path = "../model-artifact", version = "0.76.0" } +mesh-llm-events = { path = "../mesh-llm-events", version = "0.76.0" } +mesh-native-serving-plugin-api = { path = "../mesh-native-serving-plugin-api", version = "0.76.0" } +skippy-protocol = { path = "../skippy-protocol", version = "0.76.0" } +skippy-cache = { path = "../skippy-cache", version = "0.76.0" } +skippy-metrics = { path = "../skippy-metrics", version = "0.76.0" } +skippy-topology = { path = "../skippy-topology", version = "0.76.0" } +openai-frontend = { path = "../openai-frontend", version = "0.76.0" } opentelemetry-proto = "0.32.0" serde.workspace = true serde_json.workspace = true @@ -48,6 +48,6 @@ tracing = "0.1" uuid = { version = "1", features = ["v4"] } [dev-dependencies] -skippy-package-format = { path = "../skippy-package-format", version = "0.76.0-rc9" } +skippy-package-format = { path = "../skippy-package-format", version = "0.76.0" } tempfile = "3" tower = { version = "0.5", features = ["util"] } diff --git a/docs/SDK.md b/docs/SDK.md index 552693057b..6ce4c05e25 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -56,7 +56,7 @@ Add the Rust SDK facade crate: ```toml [dependencies] -mesh-llm-sdk = "0.76.0-rc9" +mesh-llm-sdk = "0.76.0" ``` The default Rust SDK feature exposes client-side mesh APIs without depending on @@ -80,7 +80,7 @@ Add the repo Swift package from a tagged GitHub release: ```swift dependencies: [ - .package(url: "https://github.com/Mesh-LLM/mesh-llm", from: "0.76.0-rc9"), + .package(url: "https://github.com/Mesh-LLM/mesh-llm", from: "0.76.0"), ], targets: [ .target( diff --git a/docs/design/NATIVE_RUNTIMES.md b/docs/design/NATIVE_RUNTIMES.md index 6adace5726..1f3063508a 100644 --- a/docs/design/NATIVE_RUNTIMES.md +++ b/docs/design/NATIVE_RUNTIMES.md @@ -47,7 +47,7 @@ transition. A native runtime is identified by: -- MeshLLM version, for example `0.76.0-rc9` +- MeshLLM version, for example `0.76.0` - Skippy ABI, for example `0.1.25` - target operating system and architecture - backend kind, for example `cpu`, `metal`, `cuda`, `rocm`, or `vulkan` @@ -381,7 +381,7 @@ Advanced users can pin runtime resolution in `~/.mesh-llm/config.toml`: ```toml [runtime.native_runtime] -mesh_version = "0.76.0-rc9" +mesh_version = "0.76.0" selection = "exact:meshllm-native-runtime-linux-x86_64-cuda12" ``` diff --git a/docs/plugins/exemplars/web-ui/Cargo.lock b/docs/plugins/exemplars/web-ui/Cargo.lock index 27e75c20d2..50fcdb1e77 100644 --- a/docs/plugins/exemplars/web-ui/Cargo.lock +++ b/docs/plugins/exemplars/web-ui/Cargo.lock @@ -397,7 +397,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mesh-llm-plugin" -version = "0.76.0-rc9" +version = "0.76.0" dependencies = [ "anyhow", "async-trait", diff --git a/docs/sdk/rust.md b/docs/sdk/rust.md index fa47c9c761..c3fb10518c 100644 --- a/docs/sdk/rust.md +++ b/docs/sdk/rust.md @@ -10,7 +10,7 @@ Client-only applications can use the default features: [dependencies] anyhow = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } -mesh-llm-sdk = "0.76.0-rc9" +mesh-llm-sdk = "0.76.0" ``` Serving applications need the `serving` feature: @@ -20,7 +20,7 @@ Serving applications need the `serving` feature: anyhow = "1" serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -mesh-llm-sdk = { version = "0.76.0-rc9", features = ["serving"] } +mesh-llm-sdk = { version = "0.76.0", features = ["serving"] } ``` Add `console` with `serving` when the embedded node should serve packaged web diff --git a/docs/sdk/swift.md b/docs/sdk/swift.md index faf9b2772a..676e5c0e6b 100644 --- a/docs/sdk/swift.md +++ b/docs/sdk/swift.md @@ -6,7 +6,7 @@ Use the GitHub Swift package from tagged `Mesh-LLM/mesh-llm` releases. ```swift dependencies: [ - .package(url: "https://github.com/Mesh-LLM/mesh-llm", from: "0.76.0-rc9"), + .package(url: "https://github.com/Mesh-LLM/mesh-llm", from: "0.76.0"), ], targets: [ .target( diff --git a/sdk/kotlin/README.md b/sdk/kotlin/README.md index e85be9680c..7dcd8d00c0 100644 --- a/sdk/kotlin/README.md +++ b/sdk/kotlin/README.md @@ -32,7 +32,7 @@ Then depend on the SDK: ```kotlin dependencies { - implementation("ai.meshllm:meshllm-android:0.76.0-rc9") + implementation("ai.meshllm:meshllm-android:0.76.0") } ``` diff --git a/sdk/kotlin/build.gradle.kts b/sdk/kotlin/build.gradle.kts index e439dfd10d..f842b5e9d3 100644 --- a/sdk/kotlin/build.gradle.kts +++ b/sdk/kotlin/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "ai.meshllm" -version = "0.76.0-rc9" +version = "0.76.0" val androidArtifactId = "meshllm-android" diff --git a/sdk/kotlin/example/example-jvm/build.gradle.kts b/sdk/kotlin/example/example-jvm/build.gradle.kts index 981d504ddd..105f7ed778 100644 --- a/sdk/kotlin/example/example-jvm/build.gradle.kts +++ b/sdk/kotlin/example/example-jvm/build.gradle.kts @@ -8,7 +8,7 @@ kotlin { } group = "ai.meshllm.example" -version = "0.76.0-rc9" +version = "0.76.0" repositories { mavenCentral() diff --git a/sdk/node/package.json b/sdk/node/package.json index 77881d5590..747cae8a57 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@mesh-llm/sdk", - "version": "0.76.0-rc9", + "version": "0.76.0", "description": "Node.js SDK for MeshLLM client and local serving applications", "main": "index.js", "types": "index.d.ts", diff --git a/sdk/swift/README.md b/sdk/swift/README.md index f61a7ca460..b489e5fe30 100644 --- a/sdk/swift/README.md +++ b/sdk/swift/README.md @@ -11,7 +11,7 @@ Add to your app's `Package.swift` using a tagged release: ```swift dependencies: [ - .package(url: "https://github.com/Mesh-LLM/mesh-llm", from: "0.76.0-rc9"), + .package(url: "https://github.com/Mesh-LLM/mesh-llm", from: "0.76.0"), ], targets: [ .target( diff --git a/sdk/swift/scripts/generate-swift-bindings.sh b/sdk/swift/scripts/generate-swift-bindings.sh index ae2a145fb8..b1e0a557d8 100755 --- a/sdk/swift/scripts/generate-swift-bindings.sh +++ b/sdk/swift/scripts/generate-swift-bindings.sh @@ -27,7 +27,7 @@ rm -f \ cat > "$RUNNER_DIR/Cargo.toml" <<'EOF' [package] name = "swift_bindgen_runner" -version = "0.76.0-rc9" +version = "0.76.0" edition = "2021" [dependencies] diff --git a/website/src/docs/pages/CLI.md b/website/src/docs/pages/CLI.md index 49cdd56d16..a5d5f96c4c 100644 --- a/website/src/docs/pages/CLI.md +++ b/website/src/docs/pages/CLI.md @@ -41,8 +41,8 @@ For the trusted-local ledger, retention, and capture guidance, see mesh-llm --version ``` -Release builds report the released package version, such as `mesh-llm 0.76.0-rc9`. -Local source builds may include build metadata, such as `mesh-llm 0.76.0-rc9+gABCDEF.dirty`, so you can tell exactly which commit produced the binary. Compatibility checks, native-runtime cache paths, and release identity still use the plain release version. +Release builds report the released package version, such as `mesh-llm 0.76.0`. +Local source builds may include build metadata, such as `mesh-llm 0.76.0+gABCDEF.dirty`, so you can tell exactly which commit produced the binary. Compatibility checks, native-runtime cache paths, and release identity still use the plain release version. ## Start here (common tasks) diff --git a/website/src/docs/pages/developing-plugins.md b/website/src/docs/pages/developing-plugins.md index f1caf97845..96e954bcdb 100644 --- a/website/src/docs/pages/developing-plugins.md +++ b/website/src/docs/pages/developing-plugins.md @@ -38,7 +38,7 @@ edition = "2024" [dependencies] anyhow = "1" -mesh-llm-plugin = "0.76.0-rc9" +mesh-llm-plugin = "0.76.0" schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" From f678ce337236e18613d434713db107465bcc65ef Mon Sep 17 00:00:00 2001 From: jy Date: Thu, 10 Sep 2026 16:58:40 +1000 Subject: [PATCH 21/41] fix(skippy-cache): enforce the L2 hard byte cap and exact charge/retained accounting (#1651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review corrections round 3 on PR #1749, all against 608ec6c38: - Hard byte cap: admission is a journaled transaction (AdmitJournal). Reservations, same-digest overwrites, orphan releases, the replaced entry, and evictions are all recorded; if eviction cannot bring the final pool under budget while the admission's own handles are pinned (e.g. an incoming entry sharing bytes with its victim), the journal is rolled back exactly — entries, handles, pool bytes, evictions counter, and charges — and the admission is refused with the new ProtectedOvercommit refusal. The pool can never exceed the budget. - Benchmark boundary: the L2 timer now starts before l2.get, so l2_lookup_to_usable_bytes_ns covers lookup through materialized bytes — the same nothing-to-usable-bytes boundary as the L3 arm. The same get is timed inner as l2_handle_lookup_ns. - Exact charges: entry charge_bytes are recomputed deterministically from the live map after every mutation (recompute_all_charges): each pooled segment is assigned exactly once to its lowest-cache-key live reference. The sum of charges always equals the physical pool after admission, eviction, removal, corruption cleanup, and rollback. - Exact retention: remove() and evict_to_limit() report retained_bytes from actual post-removal pool references (plus admission pins during eviction), never logical-minus-freed. segment_digests() dedupes, so a removed layout referencing X twice with a survivor reports size(X) once. - Ratchet: regen no-console-print allowlist (l2_tier.rs summary print was created after the last regen; main.rs lines had moved). New regressions: admission reusing protected bytes over the budget is refused and leaves the tier untouched; eviction frees only the victim's exclusive segments while pinned shared bytes survive byte-exact; survivor charge covers the pool after the sharer leaves; zero-charge sharer removal never inflates survivor charges (two- and three-sharer cases); repeated same-digest layouts report retained from the pool, with and without a survivor. Validation on this head: cargo test -p skippy-cache 150 passed / 1 ignored; cargo test -p skippy-bench 85 passed; cargo clippy -p skippy-cache -p skippy-bench --all-targets -- -D warnings clean; cargo fmt --check clean; just no-console-print passes. --- crates/skippy-bench/src/l2_tier.rs | 13 +- crates/skippy-cache/src/l2/mod.rs | 702 ++++++++++++++++-- tools/xtask/data/console_print_allowlist.json | 10 +- 3 files changed, 660 insertions(+), 65 deletions(-) diff --git a/crates/skippy-bench/src/l2_tier.rs b/crates/skippy-bench/src/l2_tier.rs index 52e8f961c3..4a7b6bac04 100644 --- a/crates/skippy-bench/src/l2_tier.rs +++ b/crates/skippy-bench/src/l2_tier.rs @@ -181,15 +181,16 @@ pub fn l2_tier(args: L2TierArgs) -> Result<()> { .context("bench L3 fill missed")?; let l3_ns = start.elapsed().as_nanos(); - // Handle-only lookup, reported separately. + // Timed through the same boundary as the L3 arm, starting before + // the lookup: the L3 timer covers index probe + assembly + + // verification, so the L2 timer covers lookup + handle assembly + + // materialization — both arms measure "nothing to usable bytes". + // The handle-only lookup time (this same `get`, inner timer) is + // reported separately as `l2_handle_lookup_ns`. + let start = Instant::now(); let handle_start = Instant::now(); let hit = l2.get(&cache_key); let l2_handle_ns = handle_start.elapsed().as_nanos(); - - // Timed through the same usable-bytes boundary as the L3 arm: the - // L3 timer covers disk assembly + verification, so the L2 timer - // covers handle lookup + assembly + materialization. - let start = Instant::now(); let l2_payload = hit.as_ref().map(|hit| hit.to_payload()); let (l2_bytes, _) = l2_payload .as_ref() diff --git a/crates/skippy-cache/src/l2/mod.rs b/crates/skippy-cache/src/l2/mod.rs index fb617e7117..6ce0658fc2 100644 --- a/crates/skippy-cache/src/l2/mod.rs +++ b/crates/skippy-cache/src/l2/mod.rs @@ -319,6 +319,13 @@ pub enum L2InsertRefusal { OverBudget { payload_bytes: u64, }, + /// Admission would push the pool past the budget with the incoming + /// entry's handles pinned: even evicting every other entry could not + /// free the excess, so the transaction was rolled back untouched. + ProtectedOvercommit { + pool_bytes: u64, + budget_bytes: u64, + }, MalformedDigest, /// Admission hashing found the wire's BLAKE3 different from the digest /// the payload claims (the L3 manifest key). @@ -354,6 +361,15 @@ impl L2InsertRefusal { "payload of {payload_bytes} distinct bytes exceeds the entire L2 budget; \ caching it would evict everything else" ), + Self::ProtectedOvercommit { + pool_bytes, + budget_bytes, + } => format!( + "admission would leave the pool at {pool_bytes} bytes against a \ + {budget_bytes}-byte budget even after evicting every unpinned entry: \ + the admission's own (shared or pinned) segments are not evictable, \ + so it was rolled back" + ), Self::MalformedDigest => { "payload digest is not a 64-hex-character blake3 string".to_string() } @@ -414,6 +430,50 @@ struct L2Inner { clock: u64, } +/// Undo log for one admission transaction. Every pool/map mutation an +/// admission performs — reserving new handles, overwriting a digest with +/// new content, releasing orphaned handles, removing the replaced entry, +/// and the entries eviction removes — is recorded here so a refused +/// admission (protected overcommit) can restore the tier exactly. Charges +/// are not journaled: every exit path recomputes them from the live map. +#[derive(Default)] +struct AdmitJournal { + reserved: Vec, + /// Previous `Arc` handles overwritten by this admission's same-digest + /// new-content installs, restored on rollback. + overwrites: Vec<(String, SegmentHandle)>, + /// Pool bytes that left with the overwritten handles. + overwritten_bytes: u64, + /// Released-orphan handles: `(digest, handle)` pairs to reinstall on + /// rollback. + released_orphans: Vec<(String, SegmentHandle)>, + removed_entries: Vec<(String, L2Entry)>, + reserved_bytes: u64, + released_bytes: u64, +} + +impl AdmitJournal { + fn rollback(self, inner: &mut L2Inner) { + for (digest, handle) in self.overwrites { + inner.segments.insert(digest, handle); + } + for digest in &self.reserved { + inner.segments.remove(digest); + } + for (digest, handle) in self.released_orphans { + inner.segments.insert(digest, handle); + } + for (key, entry) in self.removed_entries { + inner.map.insert(key, entry); + } + inner.bytes = inner + .bytes + .saturating_add(self.released_bytes) + .saturating_add(self.overwritten_bytes) + .saturating_sub(self.reserved_bytes); + } +} + /// Counters kept outside the map lock so `stats()` never blocks hits. #[derive(Default)] struct L2AtomicStats { @@ -657,53 +717,115 @@ impl L2Tier { payload_bytes: new_bytes, }); } + // Admission is all-or-nothing. Every mutation from here is + // journaled (`AdmitJournal`, module-level); if eviction cannot + // bring the final pool footprint under budget (the incoming + // entry's own handles are pinned, so an admission sharing bytes + // with its victim can exceed what eviction frees), the journal is + // rolled back and the admission is refused without touching the + // tier. + let mut journal = AdmitJournal { + reserved: Vec::new(), + overwrites: Vec::new(), + overwritten_bytes: 0, + released_orphans: Vec::new(), + removed_entries: Vec::new(), + reserved_bytes: 0, + released_bytes: 0, + }; + let protected_set: Vec = new_segments + .iter() + .map(|(digest, _)| digest.clone()) + .chain(shared.iter().cloned()) + .collect(); // Reserve the new handles in the pool before releasing anything, // so a digest re-used with new content is unambiguous from here on // and the incoming entry's bytes cannot be dropped mid-transaction. for (digest, handle) in &new_segments { - inner.bytes = inner.bytes.saturating_add(handle.bytes.len() as u64); - inner.segments.insert(digest.clone(), handle.clone()); + let handle_len = handle.bytes.len() as u64; + inner.bytes = inner.bytes.saturating_add(handle_len); + journal.reserved_bytes = journal.reserved_bytes.saturating_add(handle_len); + if let Some(previous) = inner.segments.insert(digest.clone(), handle.clone()) { + // Same digest text re-used with new content: only possible + // when replacing the same key, which still holds the old + // handle. The previous bytes leave the pool now (net + // reserved delta is `new − old`); journal them so rollback + // restores the original count. + let previous_len = previous.bytes.len() as u64; + inner.bytes = inner.bytes.saturating_sub(previous_len); + journal.overwritten_bytes = journal.overwritten_bytes.saturating_add(previous_len); + journal.overwrites.push((digest.clone(), previous)); + } else { + journal.reserved.push(digest.clone()); + } } - let protected: Vec = new_segments - .iter() - .map(|(digest, _)| digest.clone()) - .chain(shared.iter().cloned()) - .collect(); // One entry per cache key: a re-admit at the same coordinates is a // replacement (fresher state for the same prefix), not a duplicate. // The old entry's segments survive release where the incoming - // layout shares them (`protected`), so identical-wire re-admits - // never delete their own handles. + // layout shares them (`protected_set`), so identical-wire re-admits + // never delete their own handles. Released orphan handles are + // journaled so rollback reinstates them, and segments that stay + // are transfer-charged to their surviving owners before the new + // entry lands. if let Some(existing) = inner.map.remove(&cache_key) { - self.release_entry_segments(&mut inner, &existing, &protected); + let digests = existing.payload.segment_digests(); + Self::recompute_all_charges(&mut inner); + for digest in digests { + if protected_set.iter().any(|p| p == digest) { + continue; + } + let still_referenced = inner + .map + .values() + .any(|other| other.payload.segment_digests().contains(&digest)); + if still_referenced { + continue; + } + if let Some(handle) = inner.segments.remove(digest) { + let released = handle.bytes.len() as u64; + inner.bytes = inner.bytes.saturating_sub(released); + journal.released_bytes = journal.released_bytes.saturating_add(released); + journal.released_orphans.push((digest.to_string(), handle)); + } + } + journal.removed_entries.push((cache_key.clone(), existing)); } // Evict to make room: the reservation already counts toward // `inner.bytes`, so the pool (including this admission's distinct // bytes) must fit the whole budget. Shared handles are pinned and // can keep a victim from freeing — those retained bytes transfer // to this entry's charge below. - let evictions = self.evict_to_limit(&mut inner, self.budget_bytes, &cache_key, &protected); - // A victim that shared segments with this admission freed nothing: - // those bytes are now exclusively this entry's, so the charge must - // include them. (The pool may then sit above budget by exactly the - // pinned bytes the victim could not release — bounded by this - // entry's own wire.) - let mut charge_bytes = new_bytes; - for digest in &shared { - let still_shared = inner - .map - .values() - .any(|other| other.payload.segment_digests().contains(&digest.as_str())); - if !still_shared { - charge_bytes = charge_bytes.saturating_add( - inner - .segments - .get(digest) - .map(|handle| handle.bytes.len() as u64) - .unwrap_or(0), - ); - } + let evictions = self.evict_to_limit( + &mut inner, + self.budget_bytes, + &cache_key, + &protected_set, + &mut journal, + ); + // The hard byte cap: if eviction could not free the excess even by + // evicting every non-pinned entry, the admission is refused and + // rolled back — the tier never sits over budget after `admit`. + if inner.bytes > self.budget_bytes { + let pool_bytes = inner.bytes; + let rolled_back_evictions = evictions.len() as u64; + journal.rollback(&mut inner); + // Charges were recomputed during eviction; restore them to + // match the rolled-back state. + Self::recompute_all_charges(&mut inner); + self.stats + .evictions + .fetch_sub(rolled_back_evictions, Ordering::Relaxed); + let over = pool_bytes.saturating_sub(self.budget_bytes); + self.stats.refused_bytes.fetch_add(over, Ordering::Relaxed); + return Err(L2InsertRefusal::ProtectedOvercommit { + pool_bytes, + budget_bytes: self.budget_bytes, + }); } + // The entry lands with a zero charge; the deterministic recompute + // below assigns it exactly the pooled segments it owns (segments + // whose lowest-key live reference it is) and refreshes every other + // entry's charge, so the sum of charges always equals the pool. inner.clock = inner.clock.wrapping_add(1); let last_used = inner.clock; self.stats @@ -717,10 +839,13 @@ impl L2Tier { payload_digest, origin, last_used, - charge_bytes, + charge_bytes: 0, payload_bytes, }, ); + // Assign every pooled segment exactly once to its lowest-key live + // reference — the inserted entry included. + Self::recompute_all_charges(&mut inner); self.stats.inserts.fetch_add(1, Ordering::Relaxed); Ok(evictions) } @@ -758,6 +883,7 @@ impl L2Tier { // and its surviving handles; recency stays untouched. let removed = inner.map.remove(cache_key); if let Some(entry) = removed { + Self::recompute_all_charges(&mut inner); self.release_entry_segments(&mut inner, &entry, &[]); } self.stats.misses.fetch_add(1, Ordering::Relaxed); @@ -808,17 +934,39 @@ impl L2Tier { pub fn remove(&self, cache_key: &str) -> Option { let mut inner = self.inner.lock().expect("L2 map poisoned"); let removed = inner.map.remove(cache_key)?; + // Retained bytes come from the pool's actual references: segments + // of this entry that other entries still reference after the + // removal stay in the pool. Charged bytes are never transferred + // between entries — a survivor's charge already excludes shared + // segments — so this cannot drift the survivors' accounting below + // the physical pool they own. + let mut retained = 0u64; + for digest in removed.payload.segment_digests() { + let referenced_elsewhere = inner + .map + .values() + .any(|other| other.payload.segment_digests().contains(&digest)); + if referenced_elsewhere { + retained = retained.saturating_add( + inner + .segments + .get(digest) + .map(|handle| handle.bytes.len() as u64) + .unwrap_or(0), + ); + } + } + // Segments that stay are now physically owned by the survivors: + // recompute charges from the live map (the removed entry is + // already out of it). + Self::recompute_all_charges(&mut inner); let before = inner.bytes; self.release_entry_segments(&mut inner, &removed, &[]); let freed = before.saturating_sub(inner.bytes); Some(L2Eviction { cache_key: cache_key.to_string(), freed_bytes: freed, - retained_bytes: removed - .payload - .byte_len() - .saturating_sub(freed) - .min(removed.payload_bytes), + retained_bytes: retained, }) } @@ -874,6 +1022,34 @@ impl L2Tier { } } + /// Recompute every entry's charge from the live map: each pooled + /// segment is assigned exactly once, to its lowest-cache-key live + /// reference, and each entry's `charge_bytes` is the sum of the + /// segments it owns. The sum of all charges therefore always equals + /// the physical pool bytes — after admission, eviction, removal, + /// corruption cleanup, and rollback alike. Deterministic: identical + /// map states produce identical ownership. + fn recompute_all_charges(inner: &mut L2Inner) { + let mut ownership: HashMap = HashMap::new(); + for digest in inner.segments.keys() { + let Some(bytes) = inner.segments.get(digest).map(|h| h.bytes.len() as u64) else { + continue; + }; + let owner = inner + .map + .iter() + .filter(|(_, entry)| entry.payload.segment_digests().contains(&digest.as_str())) + .map(|(key, _)| key.clone()) + .min(); + if let Some(owner) = owner { + *ownership.entry(owner).or_insert(0) += bytes; + } + } + for (key, entry) in inner.map.iter_mut() { + entry.charge_bytes = ownership.get(key).copied().unwrap_or(0); + } + } + /// Drop an entry's segments from the pool, decrementing the pool byte /// total. Segments still referenced by another live entry, or pinned by /// an in-flight admission (`protected`), stay. Zero-byte segments are @@ -902,13 +1078,15 @@ impl L2Tier { /// Shared segments are released only with their last referencing /// entry; handles pinned by the in-flight admission (`protected`) are /// never released; a victim that frees nothing is still counted as an - /// eviction. + /// eviction. Every mutation is recorded in `journal` so a refused + /// admission can roll the evictions back exactly. fn evict_to_limit( &self, inner: &mut L2Inner, limit: u64, protect_key: &str, protected: &[String], + journal: &mut AdmitJournal, ) -> Vec { let mut evictions = Vec::new(); while inner.bytes > limit { @@ -925,14 +1103,56 @@ impl L2Tier { let Some(removed) = inner.map.remove(&victim) else { break; }; + // Retained bytes from actual pool references, computed before + // release: segments of the victim that survivors still + // reference, or that the in-flight admission pins (its entry + // is not in the map yet, but it will own them). + let mut retained = 0u64; + for digest in removed.payload.segment_digests() { + let referenced_elsewhere = inner + .map + .values() + .any(|other| other.payload.segment_digests().contains(&digest)) + || protected.iter().any(|p| p == digest); + if referenced_elsewhere { + retained = retained.saturating_add( + inner + .segments + .get(digest) + .map(|handle| handle.bytes.len() as u64) + .unwrap_or(0), + ); + } + } let before = inner.bytes; - self.release_entry_segments(inner, &removed, protected); + // Charges are recomputed from the live map (journaled state + // is restored exactly; ownership follows the lowest key). + Self::recompute_all_charges(inner); + for digest in removed.payload.segment_digests() { + if protected.iter().any(|p| p == digest) { + continue; + } + let still_referenced = inner + .map + .values() + .any(|other| other.payload.segment_digests().contains(&digest)); + if still_referenced { + continue; + } + if let Some(handle) = inner.segments.remove(digest) { + let released = handle.bytes.len() as u64; + inner.bytes = inner.bytes.saturating_sub(released); + journal.released_bytes = journal.released_bytes.saturating_add(released); + journal.released_orphans.push((digest.to_string(), handle)); + } + } let freed = before.saturating_sub(inner.bytes); self.stats.evictions.fetch_add(1, Ordering::Relaxed); + journal.removed_entries.push((victim.clone(), removed)); evictions.push(L2Eviction { cache_key: victim, freed_bytes: freed, - retained_bytes: removed.payload_bytes.saturating_sub(freed), + retained_bytes: retained, }); } evictions @@ -1669,43 +1889,83 @@ mod tests { #[test] fn eviction_cannot_release_segments_the_incoming_entry_shares() { // Pressure case: the incoming entry shares its would-be victim's - // segments. The victim is not protected by the cache-key filter - // (different key) and is not yet replaced in the map, so eviction - // could drop the shared handles before the new entry lands. + // prefix segments. The victim is not protected by the cache-key + // filter (different key) and is not yet replaced in the map, so + // eviction could drop the shared handles before the new entry + // lands. The 64-byte budget equals the old entry's footprint, so + // the 16-byte tail reservation forces eviction mid-admission: + // only the victim's *exclusive* tail X frees (16 bytes), the + // shared prefix is pinned by the admission and survives + // byte-exact, and the pool lands exactly at budget with the new + // entry's segments. Layout: old = [S0 S1 S2 X], grown = + // [S0 S1 S2 T] with T different content from X. let segment_len = 16u64; let total = segment_len * 4; - // Budget forces eviction: the reserved pool (64 bytes) exceeds it - // by one byte until the old entry releases its exclusive segment. - let tier = L2Tier::new(total - 1); + let tier = L2Tier::new(total); let (w, _) = wire(total as usize, 30); let short_len = segment_len * 3; let old = key("ns", &[1]); tier.admit( old.clone(), - 3, - segment_digest(&w[..short_len as usize]), - &w[..short_len as usize], - manifest_shaped_mirror(&w[..short_len as usize], segment_len), + 4, + segment_digest(&w), + &w, + manifest_shaped_mirror(&w, segment_len), L2Origin::FromL3, ) .expect("old entry admitted"); - // New key whose wire extends the old entry's segments; the budget - // forces eviction of the old entry during this admission. + // The grown entry swaps the old tail for a different one. + let tail: Vec = (0..segment_len as usize) + .map(|i| (200usize + i) % 251) + .map(|v| v as u8) + .collect(); + let mut grown_wire = w[..short_len as usize].to_vec(); + grown_wire.extend_from_slice(&tail); + let grown_digest = segment_digest(&grown_wire); + let grown_segments = vec![ + (segment_digest(&w[..segment_len as usize]), 0..segment_len), + ( + segment_digest(&w[segment_len as usize..segment_len as usize * 2]), + segment_len..segment_len * 2, + ), + ( + segment_digest(&w[segment_len as usize * 2..short_len as usize]), + segment_len * 2..short_len, + ), + (segment_digest(&tail), short_len..total), + ]; + let grown_mirror = ExactStatePayloadMirror::FullState { + layout: L2Layout { + payload_kind: ExactStatePayloadKind::FullState, + total_bytes: total, + kv_bytes: total, + recurrent_bytes: 0, + segments: grown_segments, + }, + }; let grown = key("ns", &[2]); let evictions = tier .admit( grown.clone(), 4, - segment_digest(&w[..total as usize]), - &w[..total as usize], - manifest_shaped_mirror(&w, segment_len), + grown_digest.clone(), + &grown_wire, + grown_mirror, L2Origin::FromL3, ) .expect("admission must succeed by evicting the old entry"); assert_eq!(evictions.len(), 1, "old entry is the victim"); assert_eq!(evictions[0].cache_key, old); + assert_eq!( + evictions[0].freed_bytes, segment_len, + "only the victim's exclusive tail frees; the pinned prefix stays" + ); + assert_eq!( + evictions[0].retained_bytes, short_len, + "the shared prefix is retained by the incoming entry" + ); // The shared prefix segments must have survived the eviction. let hit = tier.get(&grown).expect("grown entry hits"); @@ -1713,16 +1973,344 @@ mod tests { let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); assert_eq!( bytes.as_ref(), - &w[..total as usize], + &grown_wire[..], "shared segments must survive the admission that evicted their old owner" ); let stats = tier.stats(); assert_eq!(stats.entries, 1); assert_eq!(stats.segments, 4); - assert_eq!(stats.bytes, total); + assert_eq!( + stats.bytes, total, + "the pool is exactly the admitted entry's distinct bytes" + ); + assert!( + stats.bytes <= budget_from(&tier), + "the hard byte cap holds after a sharing admission" + ); assert!(tier.peek(&old).is_none(), "old entry was evicted"); } + /// The budget the tier was built with (test-only mirror of the + /// constructor argument). + fn budget_from(tier: &L2Tier) -> u64 { + tier.stats().budget_bytes + } + + #[test] + fn admission_reusing_protected_bytes_cannot_exceed_the_budget() { + // 100-byte budget: X (60 bytes) is live, the incoming X+Y (120 + // bytes) shares X's segment. `new_bytes` is only Y's 60, X is + // pinned (shared), so eviction cannot free the excess: the + // admission must be refused and rolled back, never inserted at + // 120 bytes over a 100-byte budget. + let tier = L2Tier::new(100); + let k1 = key("ns", &[1]); + let k2 = key("ns", &[2]); + let (x, _) = wire(60, 1); + tier.admit( + k1.clone(), + 1, + segment_digest(&x), + &x, + single_segment_mirror(&x), + L2Origin::FromL3, + ) + .expect("X admitted"); + let (y, _) = wire(60, 2); + let mut xy = x.clone(); + xy.extend_from_slice(&y); + let xy_digest = segment_digest(&xy); + let mirror = ExactStatePayloadMirror::FullState { + layout: L2Layout { + payload_kind: ExactStatePayloadKind::FullState, + total_bytes: 120, + kv_bytes: 120, + recurrent_bytes: 0, + segments: vec![(segment_digest(&x), 0..60), (segment_digest(&y), 60..120)], + }, + }; + let err = tier + .admit(k2.clone(), 2, xy_digest, &xy, mirror, L2Origin::Direct) + .expect_err("protected overcommit must be refused"); + assert_eq!( + err, + L2InsertRefusal::ProtectedOvercommit { + pool_bytes: 120, + budget_bytes: 100, + }, + "the pool could only reach the budget by evicting the pinned X" + ); + // The tier is exactly as it was before the refused admission. + let stats = tier.stats(); + assert_eq!(stats.entries, 1); + assert_eq!(stats.bytes, 60); + assert_eq!(stats.segments, 1); + assert_eq!(stats.inserts, 1, "the refusal must not count an insert"); + assert_eq!( + stats.evictions, 0, + "rolled-back evictions must not be counted" + ); + assert!(tier.peek(&k1).is_some(), "X survived the refusal"); + assert!(tier.peek(&k2).is_none(), "the incoming entry was refused"); + // X still serves its exact bytes. + let hit = tier.get(&k1).expect("X hit"); + let payload = hit.to_payload(); + let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!(bytes.as_ref(), &x[..]); + } + + #[test] + fn eviction_moves_shared_bytes_into_the_survivors_charge() { + // The short entry owns A,B; the long entry shares A,B and owns C. + // The long entry is charged only C. When the short entry is + // evicted, the pooled bytes do not change — so the survivor's + // charge must grow to the full pool, never drift below it. + let segment_len = 16u64; + let total = segment_len * 3; + let tier = L2Tier::new(1 << 20); + let short = key("ns", &[1]); + let long = key("ns", &[2]); + let (w, digest) = wire(total as usize, 70); + let short_len = segment_len * 2; + tier.admit( + short.clone(), + 2, + segment_digest(&w[..short_len as usize]), + &w[..short_len as usize], + manifest_shaped_mirror(&w[..short_len as usize], segment_len), + L2Origin::FromL3, + ) + .expect("short admitted"); + tier.admit( + long.clone(), + 3, + digest, + &w, + manifest_shaped_mirror(&w, segment_len), + L2Origin::FromL3, + ) + .expect("long admitted"); + assert_eq!( + tier.peek(&long).expect("long peeked").distinct_bytes, + total, + "before the eviction the long entry owns the shared pool outright \ + (lowest-key owner), so its charge covers the full physical pool" + ); + let removed = tier.remove(&short).expect("short present"); + assert_eq!( + removed.freed_bytes, 0, + "every short-entry segment stays in the pool under the long entry" + ); + assert_eq!( + removed.retained_bytes, short_len, + "retained bytes come from actual pool references" + ); + let stats = tier.stats(); + assert_eq!(stats.bytes, total); + assert_eq!( + tier.peek(&long).expect("long peeked").distinct_bytes, + total, + "the survivor's charge must cover the physical pool it now owns" + ); + // Aggregate invariant: the sum of all charges equals the pool. + let sum_of_charges = tier.peek(&long).expect("long").distinct_bytes; + assert_eq!(sum_of_charges, stats.bytes); + } + + #[test] + fn repeated_same_digest_segments_report_retained_bytes_from_the_pool() { + // One 16-byte segment laid out twice: the pool holds 16 bytes, the + // logical wire is 32. Removing the only entry frees 16 and retains + // nothing — `logical − freed` would have overstated retention. + let tier = L2Tier::new(1 << 20); + let k = key("ns", &[3]); + let (seg, _) = wire(16, 80); + let wire_bytes: Vec = [seg.clone(), seg.clone()].concat(); + let digest = segment_digest(&wire_bytes); + let mirror = ExactStatePayloadMirror::FullState { + layout: L2Layout { + payload_kind: ExactStatePayloadKind::FullState, + total_bytes: 32, + kv_bytes: 32, + recurrent_bytes: 0, + segments: vec![ + (segment_digest(&seg), 0..16), + (segment_digest(&seg), 16..32), + ], + }, + }; + tier.admit(k.clone(), 2, digest, &wire_bytes, mirror, L2Origin::FromL3) + .expect("admitted"); + let stats = tier.stats(); + assert_eq!(stats.bytes, 16, "the pool holds the segment once"); + assert_eq!( + tier.peek(&k).expect("peeked").distinct_bytes, + 16, + "the charge is the distinct pool bytes, not the logical wire" + ); + let removed = tier.remove(&k).expect("present"); + assert_eq!(removed.freed_bytes, 16); + assert_eq!(removed.retained_bytes, 0, "an emptied pool retains nothing"); + assert_eq!(tier.stats().bytes, 0); + assert_eq!(tier.stats().segments, 0); + } + + #[test] + fn removing_a_zero_charge_sharer_never_inflates_survivor_charges() { + // A, B, C all share segment X; only the lowest-key entry is ever + // charged for X. Removing the other sharers — whatever their key + // order — must leave the survivor's charge at exactly X's size, + // never 2x or 3x the physical pool. + let tier = L2Tier::new(1 << 20); + let ka = key("ns", &[1]); + let kb = key("ns", &[2]); + let kc = key("ns", &[3]); + let (x, dx) = wire(64, 90); + for k in [&ka, &kb, &kc] { + tier.admit( + k.clone(), + 1, + dx.clone(), + &x, + single_segment_mirror(&x), + L2Origin::FromL3, + ) + .expect("admitted"); + } + // After each removal, no surviving charge may exceed the pool. + let assert_charges_bounded = |tier: &L2Tier| { + let stats = tier.stats(); + for k in [ka.clone(), kb.clone(), kc.clone()] { + if let Some(peeked) = tier.peek(&k) { + assert!( + peeked.distinct_bytes <= stats.bytes, + "no charge may exceed the physical pool" + ); + } + } + stats + }; + // Remove the two zero-charge sharers in both orders. + tier.remove(&kb).expect("B removed"); + let stats = assert_charges_bounded(&tier); + assert_eq!(stats.bytes, 64); + tier.remove(&kc).expect("C removed"); + let stats = assert_charges_bounded(&tier); + assert_eq!(stats.bytes, 64); + // The remaining A owns X exactly once. + assert_eq!( + tier.peek(&ka).expect("A peeked").distinct_bytes, + 64, + "A's charge is X once, never the double- or triple-counted sum" + ); + assert_eq!(tier.stats().bytes, 64); + } + + #[test] + fn two_sharers_charge_moves_deterministically_to_the_lowest_key() { + let tier = L2Tier::new(1 << 20); + let ka = key("ns", &[10]); + let kb = key("ns", &[11]); + let (x, dx) = wire(48, 91); + for k in [&ka, &kb] { + tier.admit( + k.clone(), + 1, + dx.clone(), + &x, + single_segment_mirror(&x), + L2Origin::FromL3, + ) + .expect("admitted"); + } + // Exactly one of the two is charged (the lowest key), the other + // carries zero. + let charged_a = tier.peek(&ka).expect("A").distinct_bytes; + let charged_b = tier.peek(&kb).expect("B").distinct_bytes; + assert_eq!( + charged_a + charged_b, + 48, + "the sum of charges equals the physical pool" + ); + assert!(charged_a == 48 || charged_b == 48, "one owns X"); + assert!(charged_a == 0 || charged_b == 0, "the other pays nothing"); + // Remove whichever one that is not the owner: the charge sum is + // unchanged. + let (owner, zero) = if charged_a == 48 { + (ka.clone(), kb.clone()) + } else { + (kb.clone(), ka.clone()) + }; + tier.remove(&zero).expect("zero-charge sharer removed"); + assert_eq!(tier.stats().bytes, 48); + assert_eq!( + tier.peek(&owner).expect("owner peeked").distinct_bytes, + 48, + "the owner's charge is unchanged by a zero-charge removal" + ); + } + + #[test] + fn repeated_digest_with_survivor_reports_retained_once() { + // The removed entry references X twice in its layout; a survivor + // references X once. The pool retains X exactly once, so + // `retained_bytes` must be size(X) — never the double count the + // raw layout iteration would produce. + let tier = L2Tier::new(1 << 20); + let k_removed = key("ns", &[20]); + let k_survivor = key("ns", &[21]); + let (x, _) = wire(32, 95); + let x_digest = segment_digest(&x); + + // Survivor: single-segment mirror over X. + tier.admit( + k_survivor.clone(), + 1, + x_digest.clone(), + &x, + single_segment_mirror(&x), + L2Origin::FromL3, + ) + .expect("survivor admitted"); + + // Removed entry: X laid out twice (X X). + let wire_bytes: Vec = [x.clone(), x.clone()].concat(); + let digest = segment_digest(&wire_bytes); + let mirror = ExactStatePayloadMirror::FullState { + layout: L2Layout { + payload_kind: ExactStatePayloadKind::FullState, + total_bytes: 64, + kv_bytes: 64, + recurrent_bytes: 0, + segments: vec![(x_digest.clone(), 0..32), (x_digest, 32..64)], + }, + }; + tier.admit( + k_removed.clone(), + 2, + digest, + &wire_bytes, + mirror, + L2Origin::FromL3, + ) + .expect("removed entry admitted"); + let stats = tier.stats(); + assert_eq!(stats.bytes, 32, "the pool holds X once"); + + let removed = tier.remove(&k_removed).expect("removed entry present"); + assert_eq!( + removed.retained_bytes, 32, + "retained is size(X) once, not the twice-referenced 64" + ); + assert_eq!(removed.freed_bytes, 0, "the survivor keeps X"); + assert_eq!(tier.stats().bytes, 32); + assert_eq!(tier.stats().segments, 1); + let hit = tier.get(&k_survivor).expect("survivor intact"); + let payload = hit.to_payload(); + let (bytes, _) = payload.full_state_bytes_timed().expect("bytes"); + assert_eq!(bytes.as_ref(), &x[..]); + } + #[test] fn same_digest_different_bytes_is_rejected_unless_replacing_same_key() { // A second wire claiming an existing segment digest with diff --git a/tools/xtask/data/console_print_allowlist.json b/tools/xtask/data/console_print_allowlist.json index 7c3eafa525..84b39cc333 100644 --- a/tools/xtask/data/console_print_allowlist.json +++ b/tools/xtask/data/console_print_allowlist.json @@ -3651,6 +3651,12 @@ "macro_name": "println!" } ], + "crates/skippy-bench/src/l2_tier.rs": [ + { + "line": 254, + "macro_name": "println!" + } + ], "crates/skippy-bench/src/local_single.rs": [ { "line": 196, @@ -3677,11 +3683,11 @@ ], "crates/skippy-bench/src/main.rs": [ { - "line": 34, + "line": 35, "macro_name": "eprintln!" }, { - "line": 42, + "line": 43, "macro_name": "eprintln!" } ], From fc299748f00af79fdf81cc0e6782c9d70f7c10ac Mon Sep 17 00:00:00 2001 From: James Dumay Date: Thu, 10 Sep 2026 19:50:04 +1000 Subject: [PATCH 22/41] fix(ci): simplify llama canary state machine (#1727) * fix(ci): simplify llama canary state machine * fix(ci): surface canary PR creation errors * fix(ci): address llama canary review findings --------- --- .../manage-ci/references/current-inventory.md | 70 +- .github/workflows/llama-upstream-canary.yml | 349 +++---- ci/ci.md | 28 +- ci/llama-canary/agent-repair-prompt.md | 63 +- scripts/llama-canary-agent-repair.sh | 898 +++++++----------- ...test_llama_canary_agent_repair_contract.py | 601 +++++------- .../test_llama_upstream_canary_contract.py | 281 ++---- 7 files changed, 866 insertions(+), 1424 deletions(-) diff --git a/.agents/skills/manage-ci/references/current-inventory.md b/.agents/skills/manage-ci/references/current-inventory.md index fc62f3d43c..ceabb14d42 100644 --- a/.agents/skills/manage-ci/references/current-inventory.md +++ b/.agents/skills/manage-ci/references/current-inventory.md @@ -52,12 +52,9 @@ shard, and requires every shard that carries `*.block_count` and before compilation; Qwen4 experimental artifacts derive their wider boundary from `hyper_connection.count * embedding_length`. It emits deterministic bounded GitHub matrix shards; the current one-runner topology consumes one -selected-family shard while retaining the plan as evidence. On a changed llama.cpp pin, -the canary diffs the actual old and new upstream revisions inside the prepared llama.cpp -checkout. A change limited to model implementation files named by the generated-family -map selects their certified families plus fixed architecture sentinels. Any shared -upstream source, unmapped model source, or unavailable diff fails closed to the full -bump battery; non-bump runs retain their cadence-owned cohort. The runner's `.env` exports +selected-family shard while retaining the plan as evidence. Changed llama.cpp pins +always run the complete `llama-bump` family cohort; non-bump runs retain their +cadence-owned cohort. The runner's `.env` exports `HF_CACHE` pointing at a pre-warmed HF cache that lives on the lab NFS models volume and `HF_HUB_OFFLINE=1` (NFS offers no `flock`, so `hf` on the runner is read-only; the cache is populated by a two-stage prewarm that downloads on @@ -84,35 +81,38 @@ evidence, and logs are uploaded for 14 days even when the battery fails. Stage readiness uses a declared per-model override or a model-size-derived deadline, each complete certification has a portable process-group wall-clock limit, and the workflow's outer battery -ceiling is 12 hours. On a -patch-apply failure it hands the queue to a non-interactive `opencode` agent -(`CANARY_AGENT_MODEL`, default `zai-coding-plan/glm-5.3-flash`, overridable -via the `LLAMA_CANARY_AGENT_MODEL` repository variable) which rebases -`third_party/llama.cpp/patches`, runs the supported-families certification -battery (`scripts/skippy-family-battery.sh`), and keeps the repair local until -the run reaches terminal success or failure. Only then does the wrapper publish -or reuse the repair PR on `llama-canary/patch-queue-fix`. The deterministic -wrapper writes the sole -upstream selector, `third_party/llama.cpp/upstream.txt`, to the resolved repair -target, prepares through the checked-in `pinned` -selector, and verifies the prepared-upstream stamp before any repair branch is -published or certified. The same repair loop also runs when the queue applies -but a certification lane fails (`battery` mode). After each agent turn the -repair script itself runs the battery and, on failure, loops certify -> agent fix -> -recertify up to `CANARY_REPAIR_MAX_TURNS` (default 2) turns; the script only -succeeds when the wrapper's own battery run passes. After the first green -battery, a fresh-context semantic review runs locally; any changes it makes -must pass the complete battery again. Every terminal outcome (battery green, -queue still broken, battery exhausted) then publishes the branch and posts a -status comment on the repair PR — creating the PR (or a fallback issue) itself -if the agent did not — and an earlier agent turn writes the PR description (key -upstream changes, patch-queue evolution, risks) with a deterministic fallback. Repair pushes and -PR operations authenticate with the `CANARY_REPAIR_TOKEN` fine-grained PAT; -the canary job itself remains `contents: read`. Any repair outcome keeps the -canary run red: the certified fix must be merged from the repair PR before -trusted main can certify. The upstream pin commit to -`main` is gated on the battery passing and writes the sole upstream pin from -the validated SHA. +ceiling is 12 hours. A changed pin runs one deterministic wrapper-owned state +machine: `prepare -> build -> certify -> publish`. The wrapper writes the sole +upstream selector, `third_party/llama.cpp/upstream.txt`, prepares through the +checked-in `pinned` selector, verifies the prepared-upstream stamp, completes +the patched llama.cpp/native-test and Rust build gates, and then runs the full +supported-family certification. A failed phase is handed to a non-interactive +`opencode` agent (`CANARY_AGENT_MODEL`, default +`zai-coding-plan/glm-5.3-flash`, overridable through +`LLAMA_CANARY_AGENT_MODEL`). The agent may run focused diagnostics and edit the +local tree, but the wrapper restarts at prepare, reruns the complete build, and +remains the sole authority for certification. Each phase permits +`CANARY_REPAIR_MAX_TURNS` (default 2). The wrapper has a 690-minute internal +work deadline inside the 720-minute Actions step and reserves 30 minutes for +terminal publication. It publishes once to the unique +`llama-canary/repair---` branch. A certified terminal +state opens a normal PR bound to the exact green commit; a turn- or time-bounded +failure opens a draft PR preserving the last attempted bytes. The PR body +includes a deterministic upstream diffstat and commit summary even though the +unchanged-pin workflow summary path is skipped. Scheduled runs query open +`llama-canary/repair-*` PR bodies before starting the state machine and skip an +exact candidate SHA already under review. No agent turn runs after green, and +changed pins are never pushed directly to `main`. Repair pushes and PR +operations authenticate with the `CANARY_REPAIR_TOKEN` fine-grained PAT; Git +receives it through a run-scoped askpass helper instead of a credential-bearing +URL, and stderr redaction uses literal replacement. The wrapper validates the +native build directory, HF cache, repository identity, and repair token before +the first phase. The canary job itself remains `contents: read`; the dedicated +repair PAT performs the bounded PR lookup. Changed-pin evidence uses its own +`llama-canary-changed-pin-*` artifact namespace. Every +changed-pin outcome keeps the canary run red until a certified PR is reviewed +and merged. Unchanged scheduled and forced certifications stay read-only and +never invoke the repair agent. For a non-canary manual dispatch, `release.yml` runs the checked-in `scripts/release-version.sh`, creates one linear release-source commit when the diff --git a/.github/workflows/llama-upstream-canary.yml b/.github/workflows/llama-upstream-canary.yml index 1ed236af9e..ffd8f0958c 100644 --- a/.github/workflows/llama-upstream-canary.yml +++ b/.github/workflows/llama-upstream-canary.yml @@ -16,8 +16,9 @@ on: # This workflow always checks out and executes trusted default-branch content. # It must never execute a caller-selected ref on the persistent -# `family-certify` self-hosted runner. The certification job is read-only; a -# separate hosted job performs the narrow pin write after certification passes. +# `family-certify` self-hosted runner. The job token is read-only. Changed pins +# are prepared, built, certified, and published once through a wrapper-owned +# run-specific PR; unchanged nightly sentinels remain read-only. permissions: contents: read @@ -27,15 +28,6 @@ jobs: timeout-minutes: 780 permissions: contents: read - outputs: - old_sha: ${{ steps.sha.outputs.old_sha }} - new_sha: ${{ steps.sha.outputs.new_sha }} - changed: ${{ steps.sha.outputs.changed }} - certify: ${{ steps.sha.outputs.certify }} - cadence: ${{ steps.sha.outputs.cadence }} - family_patch_outcome: ${{ steps.family_patch.outcome }} - battery_outcome: ${{ steps.battery.outcome }} - trusted_queue_sha: ${{ steps.sha.outputs.trusted_queue_sha }} env: LLAMA_UPSTREAM_CANARY_SMOKE: ${{ vars.LLAMA_UPSTREAM_CANARY_SMOKE || '1' }} # Certification must compile the native closure for this arm64 runner. @@ -101,35 +93,35 @@ jobs: exit 1 fi - - name: Prepare requested llama.cpp upstream - id: prepare - continue-on-error: true + - name: Resolve requested llama.cpp upstream + id: sha env: UPSTREAM_SHA: ${{ github.event.inputs.upstream_sha || 'latest' }} - run: scripts/prepare-llama.sh "$UPSTREAM_SHA" - - - name: Capture upstream SHAs - id: sha - if: steps.prepare.outcome == 'success' run: | + set -euo pipefail old_sha="$(tr -d '[:space:]' < third_party/llama.cpp/upstream.txt)" - new_sha="$(tr -d '[:space:]' < .deps/llama.cpp/.mesh-llm-upstream-sha)" - trusted_queue_sha="$(git rev-parse HEAD)" + new_sha="$UPSTREAM_SHA" + if [[ "$new_sha" == "latest" || -z "$new_sha" ]]; then + new_sha="$(git ls-remote https://github.com/ggml-org/llama.cpp.git master | awk '{print $1}')" + fi + if [[ ! "$new_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "requested upstream must resolve to a 40-hex commit" >&2 + exit 1 + fi { echo "old_sha=$old_sha" echo "new_sha=$new_sha" - echo "trusted_queue_sha=$trusted_queue_sha" if [[ "$old_sha" == "$new_sha" ]]; then echo "changed=false" else echo "changed=true" fi - if [[ "$FORCE_CERTIFY" == "true" ]]; then - echo "certify=true" - echo "cadence=manual-full" - elif [[ "$old_sha" != "$new_sha" ]]; then + if [[ "$old_sha" != "$new_sha" ]]; then echo "certify=true" echo "cadence=llama-bump" + elif [[ "$FORCE_CERTIFY" == "true" ]]; then + echo "certify=true" + echo "cadence=manual-full" elif [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then echo "certify=true" echo "cadence=nightly" @@ -139,64 +131,106 @@ jobs: fi } >> "$GITHUB_OUTPUT" + - name: Detect existing changed-pin canary PR + id: existing_changed_pin + if: github.event_name == 'schedule' && steps.sha.outputs.changed == 'true' + env: + CANDIDATE_SHA: ${{ steps.sha.outputs.new_sha }} + GH_TOKEN: ${{ secrets.CANARY_REPAIR_TOKEN }} + run: | + set -euo pipefail + pr="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --search "$CANDIDATE_SHA in:body" \ + --limit 100 \ + --json number,headRefName,body \ + | jq -r --arg sha "$CANDIDATE_SHA" \ + 'first( + .[] + | select(.headRefName | startswith("llama-canary/repair-")) + | select((.body // "") | contains("- Candidate pin: `\($sha)`")) + | .number + ) // empty' + )" + if [[ -n "$pr" ]]; then + echo "found=true" >> "$GITHUB_OUTPUT" + echo "pr=$pr" >> "$GITHUB_OUTPUT" + echo "scheduled changed-pin certification already has open PR #${pr} for ${CANDIDATE_SHA}" + else + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Changed-pin prepare, build, certify, and publish + id: changed_canary + if: steps.sha.outputs.changed == 'true' && steps.existing_changed_pin.outputs.found != 'true' + timeout-minutes: 720 + continue-on-error: true + env: + CANARY_AGENT_MODEL: ${{ vars.LLAMA_CANARY_AGENT_MODEL || 'zai-coding-plan/glm-5.3-flash' }} + CANARY_REPAIR_TOKEN: ${{ secrets.CANARY_REPAIR_TOKEN }} + CANARY_REPAIR_BUDGET_SECONDS: "41400" + CANARY_PUBLISH_RESERVE_SECONDS: "1800" + FAMILY_BATTERY_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + UPSTREAM_SHA_INPUT: ${{ steps.sha.outputs.new_sha }} + run: scripts/llama-canary-agent-repair.sh + + - name: Upload changed-pin canary evidence + if: ${{ !cancelled() && steps.sha.outputs.changed == 'true' && steps.existing_changed_pin.outputs.found != 'true' }} + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: llama-canary-changed-pin-${{ github.run_id }}-${{ github.run_attempt }} + path: | + target/family-battery/${{ github.run_id }}-${{ github.run_attempt }}/ + target/skippy-stage-rewriter-check/ + .deps/llama-canary-state-${{ github.run_id }}-${{ github.run_attempt }}/ + if-no-files-found: warn + retention-days: 14 + + - name: Stop after changed-pin terminal PR + if: ${{ !cancelled() && steps.sha.outputs.changed == 'true' }} + env: + CHANGED_CANARY_OUTCOME: ${{ steps.changed_canary.outcome }} + DEDUPLICATION_OUTCOME: ${{ steps.existing_changed_pin.outcome }} + EXISTING_CANARY_FOUND: ${{ steps.existing_changed_pin.outputs.found }} + EXISTING_CANARY_PR: ${{ steps.existing_changed_pin.outputs.pr }} + run: | + { + echo "## llama.cpp changed-pin canary" + echo + if [[ "$EXISTING_CANARY_FOUND" == "true" ]]; then + echo "Skipped duplicate scheduled certification because open canary PR #${EXISTING_CANARY_PR} already records this exact candidate SHA." + elif [[ "$DEDUPLICATION_OUTCOME" == "failure" ]]; then + echo "The scheduled duplicate-PR lookup failed before the changed-pin state machine started. No branch or PR was created; inspect that step for the GitHub API error." + elif [[ "$CHANGED_CANARY_OUTCOME" == "success" ]]; then + echo "The deterministic state machine certified the candidate and published its exact terminal commit in a run-specific PR." + else + echo "The changed-pin state machine ended with outcome **${CHANGED_CANARY_OUTCOME}**." + echo "If terminal publication completed, the log names the preserved draft PR. A preflight failure can stop before any branch or PR is created." + fi + echo "The canary remains red until a certified PR is reviewed and merged." + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + + - name: Prepare pinned llama.cpp upstream + id: prepare + if: steps.sha.outputs.changed != 'true' && steps.sha.outputs.certify == 'true' + run: scripts/prepare-llama.sh pinned + - name: Parity manifest validation (boundary registration gate) id: parity_validate # Fail closed when llama-parity-candidates.json is not fully # classified: runnable rows must register begin_block/end_block (or # carry an explicit unsupported_reason on a non-runnable # classification), and every model_pin must be immutable. A failure - # here is a manifest defect, not an upstream defect — route it to - # the same battery-mode agent repair loop so the manifests get - # fixed in the repair PR instead of silently certifying. - # continue-on-error mirrors the battery step: the "Fail when the - # canary needs human attention" step keeps the job red unless the - # repair resolves it. + # here is a manifest defect. Keep the remaining evidence-producing + # steps available, then fail the unchanged-pin run at the final gate. if: steps.prepare.outcome == 'success' && steps.sha.outputs.certify == 'true' continue-on-error: true run: | set -o pipefail - mkdir -p .deps - # Tee into the head of the repair wrapper's battery evidence log - # so a battery-mode repair turn sees the manifest failures without - # re-running anything; the battery step below appends to the same - # log. The wrapper clears this file at the start of its own runs. - echo "=== parity manifest validation (boundary registration gate) ===" \ - | tee .deps/llama-canary-repair-battery.log - python3 scripts/skippy-llama-parity.py --llama-src .deps/llama.cpp validate 2>&1 \ - | tee -a .deps/llama-canary-repair-battery.log - - - name: Select changed generated families - id: family_selection - if: steps.prepare.outcome == 'success' && steps.sha.outputs.certify == 'true' - run: | - set -euo pipefail - mkdir -p target/skippy-stage-rewriter-check - selection=target/skippy-stage-rewriter-check/family-selection.json - mode=full - families="" - if [[ "${{ steps.sha.outputs.cadence }}" == "llama-bump" ]] \ - && git -C .deps/llama.cpp diff --name-only \ - "${{ steps.sha.outputs.old_sha }}" \ - "${{ steps.sha.outputs.new_sha }}" \ - -- > /tmp/changed-llama-sources.txt; then - python3 scripts/select-skippy-family-shards.py \ - --changed-paths /tmp/changed-llama-sources.txt \ - --family-map ci/llama-canary/generated-family-map.json \ - --include-sentinels \ - --output "$selection" - mode="$(jq -r .mode "$selection")" - families="$(jq -r '.families | join(",")' "$selection")" - if [[ "$mode" != "targeted" || -z "$families" ]]; then - mode=full - families="" - fi - else - printf '%s\n' '{"families": [], "mode": "full", "reason": "non-bump-or-upstream-diff-unavailable"}' > "$selection" - fi - { - echo "mode=$mode" - echo "families=$families" - } >> "$GITHUB_OUTPUT" + python3 scripts/skippy-llama-parity.py --llama-src .deps/llama.cpp validate - name: Plan and verify family certification cache id: family_plan @@ -206,14 +240,9 @@ jobs: run: | set -euo pipefail plan="target/family-battery/$FAMILY_BATTERY_RUN_ID/policy-plan.json" - family_args=() - if [[ "${{ steps.family_selection.outputs.mode }}" == "targeted" ]]; then - family_args=(--families "${{ steps.family_selection.outputs.families }}") - fi python3 scripts/plan-family-battery.py \ --manifest ci/llama-canary/family-certified.json \ --cadence "${{ steps.sha.outputs.cadence }}" \ - "${family_args[@]}" \ --shard-count 1 \ --check-cache \ --cache-root "$HF_CACHE" \ @@ -271,8 +300,7 @@ jobs: # producers (mesh-llm host binary + patched native runtime bundle # from this run's llama tree; backend explicit per runner) and # records producer-provenance.json — a cached binary/bundle cannot - # supply false provenance. Failure routes to the same battery-mode - # agent repair loop; per-row evidence lands under the family + # supply false provenance. Per-row evidence lands under the family # battery evidence root and is uploaded with it. if: steps.prepare.outcome == 'success' && steps.sha.outputs.certify == 'true' && steps.family_patch.outcome == 'success' && env.LLAMA_UPSTREAM_CANARY_SMOKE != '0' && env.LLAMA_UPSTREAM_CANARY_SMOKE != 'false' continue-on-error: true @@ -280,16 +308,13 @@ jobs: FAMILY_BATTERY_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} SKIPPY_CANARY_LIVE_MATRIX_BACKEND: metal SKIPPY_CANARY_LIVE_MATRIX_ROOT: ${{ github.workspace }}/target/family-battery/${{ github.run_id }}-${{ github.run_attempt }} - run: | - set -o pipefail - scripts/skippy-canary-live-matrix.sh --prepare 2>&1 | tee -a .deps/llama-canary-repair-battery.log + run: scripts/skippy-canary-live-matrix.sh --prepare - name: Supported-families certification battery (parity gate) id: battery if: steps.prepare.outcome == 'success' && steps.sha.outputs.certify == 'true' && steps.family_patch.outcome == 'success' - # continue-on-error so the battery-failure agent repair loop below can - # run; the "Fail when the canary needs human attention" step keeps the - # job red for any battery failure the repair does not resolve. + # Continue so evidence uploads and the explicit unchanged-pin failure + # summary still run after a red family lane. continue-on-error: true # Preflight resolves every cached model to an immutable snapshot before # any certification starts. Individual certifications are bounded by @@ -297,16 +322,7 @@ jobs: timeout-minutes: 720 env: FAMILY_BATTERY_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} - run: | - set -o pipefail - # Tee the battery output to the repair wrapper's evidence path so a - # battery-failure repair turn reuses this run's evidence instead of - # re-running the (up to 720-minute) battery before its first agent - # turn. The wrapper clears this file at the start of its own runs. - # Append: the parity validation gate above may have already - # written its failures to the head of this log. - scripts/skippy-family-battery.sh --skip-build --plan "${{ steps.family_plan.outputs.plan_path }}" 2>&1 \ - | tee -a .deps/llama-canary-repair-battery.log + run: scripts/skippy-family-battery.sh --skip-build --plan "${{ steps.family_plan.outputs.plan_path }}" - name: Upload supported-families battery evidence if: ${{ !cancelled() && steps.prepare.outcome == 'success' && steps.sha.outputs.certify == 'true' }} @@ -330,27 +346,6 @@ jobs: .deps/llama.cpp \ > /tmp/llama-upstream-pr.md - - name: Report upstream pin update - # Truthful success reporting: parity validation, the live matrix, - # and the generated-family-patch gate are independent fail-closed lanes — a - # green battery alone must not print "pin eligible" while another - # lane failed. - if: >- - steps.prepare.outcome == 'success' - && steps.sha.outputs.changed == 'true' - && steps.battery.outcome == 'success' - && steps.parity_validate.outcome == 'success' - && steps.live_matrix.outcome == 'success' - && steps.family_patch.outcome == 'success' - run: | - { - echo "## llama.cpp upstream pin update" - echo - echo "The validated upstream pin is eligible for the isolated update job after the policy-driven family battery passed." - echo - cat /tmp/llama-upstream-pr.md - } >> "$GITHUB_STEP_SUMMARY" - - name: Report forced certification result # Same truthfulness: all certification lanes green, not battery only. if: >- @@ -389,126 +384,18 @@ jobs: cat /tmp/llama-upstream-pr.md } >> "$GITHUB_STEP_SUMMARY" - - name: Agent repair loop (patch-queue failure) - id: repair_queue - # Repair the patch queue with the opencode agent before failing the - # run. The wrapper itself re-runs the battery and only succeeds when - # certification passes. It publishes or updates the repair PR only - # after reaching terminal success or failure. Requires opencode + - # agent credentials and - # CANARY_REPAIR_TOKEN on the family-certify runner (see - # ci/llama-canary/agent-repair-prompt.md). - if: steps.prepare.outcome == 'failure' - timeout-minutes: 720 - continue-on-error: true - env: - CANARY_AGENT_MODEL: ${{ vars.LLAMA_CANARY_AGENT_MODEL || 'zai-coding-plan/glm-5.3-flash' }} - CANARY_REPAIR_TOKEN: ${{ secrets.CANARY_REPAIR_TOKEN }} - # Post-green review: after a certified repair, a fresh-context - # agent turn reviews the local candidate and may modify it. Any - # separate review(llama): commit is recertified before publication - # (see scripts/llama-canary-agent-repair.sh). Opt out repo-wide by - # setting LLAMA_CANARY_AGENT_REVIEW=false. - CANARY_AGENT_REVIEW: ${{ vars.LLAMA_CANARY_AGENT_REVIEW || 'true' }} - # Untrusted dispatch values reach Bash only as environment - # variables, never through inline interpolation. - UPSTREAM_SHA_INPUT: ${{ github.event.inputs.upstream_sha || 'latest' }} - run: | - scripts/llama-canary-agent-repair.sh patch-queue - - - name: Agent repair loop (battery failure) - id: repair_battery - # The queue applied but a certification lane (or the parity manifest - # validation gate) failed: hand the failing battery output to the - # same repair loop. Skipped when the patch-queue repair already ran - # or passed everything. - if: > - steps.prepare.outcome == 'success' + - name: Fail unsuccessful unchanged-pin certification + if: >- + steps.sha.outputs.changed != 'true' && steps.sha.outputs.certify == 'true' - && steps.sha.outputs.cadence != 'nightly' && (steps.battery.outcome == 'failure' || steps.parity_validate.outcome == 'failure' || steps.live_matrix.outcome == 'failure' || steps.family_patch.outcome == 'failure') - timeout-minutes: 720 - continue-on-error: true - env: - CANARY_AGENT_MODEL: ${{ vars.LLAMA_CANARY_AGENT_MODEL || 'zai-coding-plan/glm-5.3-flash' }} - CANARY_REPAIR_TOKEN: ${{ secrets.CANARY_REPAIR_TOKEN }} - # Post-green review: same opt-out var as the patch-queue step. - CANARY_AGENT_REVIEW: ${{ vars.LLAMA_CANARY_AGENT_REVIEW || 'true' }} - # Untrusted dispatch values reach Bash only as environment - # variables, never through inline interpolation. - UPSTREAM_SHA_INPUT: ${{ steps.sha.outputs.new_sha }} - run: | - scripts/llama-canary-agent-repair.sh battery - - - name: Fail when the canary needs human attention - # Any repair outcome leaves the run red: if the repair loop failed, - # the agent is stuck; if it succeeded, the certified fix lives on the - # repair PR branch and a human must merge it before the trusted main - # queue can certify. The repair PR carries the details. - if: > - contains(fromJSON('["success", "failure"]'), steps.repair_queue.outcome) - || contains(fromJSON('["success", "failure"]'), steps.repair_battery.outcome) - || steps.battery.outcome == 'failure' - || steps.parity_validate.outcome == 'failure' - || steps.live_matrix.outcome == 'failure' - || steps.family_patch.outcome == 'failure' - env: - CANARY_CADENCE: ${{ steps.sha.outputs.cadence }} run: | - if [[ "$CANARY_CADENCE" == "nightly" ]]; then - { - echo "## llama.cpp nightly family sentinels failed" - echo - echo "The bounded trusted-main nightly battery failed. Inspect the uploaded family evidence; the upstream patch-queue repair agent was intentionally not invoked for an unchanged pin." - } >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi { - echo "## llama.cpp canary needs human attention" + echo "## llama.cpp unchanged-pin certification failed" echo - echo "The agent repair loop ran; review and merge the repair PR on" - echo "\`llama-canary/patch-queue-fix\` (it contains the agent's work, the upstream" - echo "analysis, and — if the loop did not finish green — where it is stuck). If the" - echo "repair loop could not even start, check the run log for missing opencode," - echo "agent credentials, or CANARY_REPAIR_TOKEN on this runner." + echo "Inspect the uploaded family evidence. Agent repair is reserved for changed upstream pins; unchanged nightly and forced checks never modify or publish repository state." } >> "$GITHUB_STEP_SUMMARY" exit 1 - - update-pin: - needs: latest-upstream - if: needs.latest-upstream.outputs.changed == 'true' && needs.latest-upstream.outputs.family_patch_outcome == 'success' && needs.latest-upstream.outputs.battery_outcome == 'success' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - # Commit exactly on the default-branch tree that was certified. The - # non-force push below fails safely if main advanced meanwhile. - ref: ${{ needs.latest-upstream.outputs.trusted_queue_sha }} - persist-credentials: false - - - name: Commit validated upstream pin to main - env: - PIN_PUSH_TOKEN: ${{ github.token }} - VALIDATED_SHA: ${{ needs.latest-upstream.outputs.new_sha }} - TRUSTED_QUEUE_SHA: ${{ needs.latest-upstream.outputs.trusted_queue_sha }} - run: | - set -euo pipefail - if [[ ! "$VALIDATED_SHA" =~ ^[0-9a-f]{40}$ || ! "$TRUSTED_QUEUE_SHA" =~ ^[0-9a-f]{40}$ ]]; then - echo "validated SHA output is malformed" >&2 - exit 1 - fi - scripts/update-llama-pin.sh "$VALIDATED_SHA" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add third_party/llama.cpp/upstream.txt - if git diff --cached --quiet; then - echo "No upstream pin changes to commit." - exit 0 - fi - git commit -m "Update llama.cpp upstream pin" - git push "https://x-access-token:${PIN_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:refs/heads/main diff --git a/ci/ci.md b/ci/ci.md index 2609f9d712..c3563d7478 100644 --- a/ci/ci.md +++ b/ci/ci.md @@ -26,19 +26,27 @@ and acceptance criteria are in `.omo/specs/pr-ci-optimization.md`. | `nightly-stability.yml` / `nightly-stability-run.yml` | daily schedule, dispatch / reusable | GitHub-hosted live-endpoint evidence. The general stability and KV tool-loop/prefix-reuse harnesses run independently, upload both evidence sets, and preserve either failure. The reusable workflow accepts no runner label. | | `nightly-kv-coverage.yml` | daily schedule, dispatch | Trusted-`main`, read-only, GitHub-hosted expansion of deterministic radix lease/eviction and blob-ownership state machines. Seed/step budgets and the exact source SHA are uploaded; no secrets or privileged runner are used. | | `agentic-replay-nightly.yml` | daily schedule, trusted-main dispatch | Opt-in coding-agent replay benchmark on the persistent macOS `micstudio` runner. The fixed `[self-hosted, X64, macOS, family-certify, agentic-replay]` selector is backed by a fail-closed `RUNNER_NAME=micstudio` check; both manual and scheduled execution check out trusted `main`. It resolves exact model and trajectory revisions from the pre-warmed Hugging Face cache, verifies their SHA-256 digests, uploads immutable evidence, publishes cohort-matched history, gates configured regressions, and may open a repair PR without executing pull-request content on the persistent runner. | -| `llama-upstream-canary.yml` | daily schedule, dispatch | Trusted default-branch llama.cpp bump certification on the self-hosted `family-certify` runner. It never runs as ordinary push or PR CI. `scripts/plan-family-battery.py` validates the generated `ci/llama-canary/family-certified.json` policy (sourced from `ci/model-artifacts/registry.json`) and every file's exact immutable cache blob identity and byte size before native compilation. Each target/draft artifact must have at least one metadata-bearing GGUF shard; every shard that carries architecture dimensions must match the declared runtime range and activation width, including Qwen4's `hyper_connection.count * embedding_length` boundary. Optional `mmproj_artifact` rows pin a projector GGUF sidecar (exact blob identity, exempt from trunk-dimension checks), and each family that pins one runs an additional multimodal smoke lane after its core lanes: the real-projector + deterministic-image harness in `crates/skippy-server/src/frontend/tests/multimodal.rs` (local monolithic and split stages) via `SKIPPY_MM_*`, reconciled against the plan like every other lane. It emits deterministic bounded matrix shards and records the plan with evidence. The current single-runner workflow consumes one selected-family shard and builds the certification binaries once. For a changed llama.cpp pin, the canary diffs the actual old and new upstream revisions in the prepared llama.cpp checkout. Changes limited to mapped `src/models/*.cpp` files run the affected manifest families plus fixed architecture sentinels; shared or unmapped upstream sources and unavailable diffs fail closed to the full supported-family battery. Before any lane starts, the battery verifies shard/tensor scans, declared runtime/MTP layer counts, model bytes, disk headroom and certification ports. Native MTP/NextN heads remain part of the single target model; the battery does not reopen the model as a separate draft. Those rows require native draft sidebands in staged single-step and chain correctness, where each proposed token is verified against the target. Every certified profile must retain strict `single-step`, `chain`, and `state-handoff` parity. Single-step and chain exercise the sole shipping raw-f32 activation wire and any mismatch is a hard failure. Planned families, sweep cuts, and multimodal smokes are reconciled exactly against executed lanes and recorded results. Declared per-model or model-size-derived startup deadlines, complete-certification wall-clock limits and typed lane outcomes are recorded, and immutable plans/model manifests/preflight evidence/certification logs upload even on failure. Manual dispatch can force this certification when the upstream SHA is unchanged. Persistent-runner execution is always a read-only checkout of trusted `main`; patch-apply failures and certification-lane failures route through the agent repair loop (`scripts/llama-canary-agent-repair.sh`), which keeps repair and post-green review work local, re-certifies any review edits, and publishes the repair branch and PR on `llama-canary/patch-queue-fix` only at terminal success or failure for human review — the canary run stays red until that PR merges. After a successful changed-pin battery, a separate GitHub-hosted write-only job commits on the exact certified `main` SHA and fails safely if `main` advanced. Runner reads its pre-warmed HF cache over NFS (`HF_CACHE` + `HF_HUB_OFFLINE=1` in the runner `.env`; no `flock` on NFS, so the runner never downloads) | - -The canary repair wrapper owns the target-pin transition: it writes the sole -upstream selector, `third_party/llama.cpp/upstream.txt`, prepares through the -checked-in `pinned` selector, and -verifies the prepared-upstream stamp before publishing or certifying a repair -branch. The hosted changed-pin job uses the same updater. +| `llama-upstream-canary.yml` | daily schedule, dispatch | Trusted default-branch llama.cpp bump certification on the self-hosted `family-certify` runner. It never runs as ordinary push or PR CI. `scripts/plan-family-battery.py` validates the generated `ci/llama-canary/family-certified.json` policy (sourced from `ci/model-artifacts/registry.json`) and every file's exact immutable cache blob identity and byte size before native compilation. Each target/draft artifact must have at least one metadata-bearing GGUF shard; every shard that carries architecture dimensions must match the declared runtime range and activation width, including Qwen4's `hyper_connection.count * embedding_length` boundary. Optional `mmproj_artifact` rows pin a projector GGUF sidecar (exact blob identity, exempt from trunk-dimension checks), and each family that pins one runs an additional multimodal smoke lane after its core lanes: the real-projector + deterministic-image harness in `crates/skippy-server/src/frontend/tests/multimodal.rs` (local monolithic and split stages) via `SKIPPY_MM_*`, reconciled against the plan like every other lane. It emits deterministic bounded matrix shards and records the plan with evidence. The current single-runner workflow consumes one selected-family shard and builds the certification binaries once. Changed pins always run the complete `llama-bump` cohort. Before any lane starts, the battery verifies shard/tensor scans, declared runtime/MTP layer counts, model bytes, disk headroom and certification ports. Native MTP/NextN heads remain part of the single target model; the battery does not reopen the model as a separate draft. Those rows require native draft sidebands in staged single-step and chain correctness, where each proposed token is verified against the target. Every certified profile must retain strict `single-step`, `chain`, and `state-handoff` parity. Single-step and chain exercise the sole shipping raw-f32 activation wire and any mismatch is a hard failure. Planned families, sweep cuts, and multimodal smokes are reconciled exactly against executed lanes and recorded results. Declared per-model or model-size-derived startup deadlines, complete-certification wall-clock limits and typed lane outcomes are recorded, and immutable plans/model manifests/preflight evidence/certification logs upload even on failure. Manual dispatch can force this certification when the upstream SHA is unchanged. Persistent-runner execution is always a read-only checkout of trusted `main`. Changed pins route through one deterministic `prepare -> build -> certify -> publish` wrapper (`scripts/llama-canary-agent-repair.sh`). A failed phase may receive bounded local agent repairs, but every edit restarts prepare and the complete build before the wrapper can certify. The 690-minute internal work deadline reserves 30 minutes inside the 720-minute step for one terminal publication to a run-specific branch. Green opens a normal exact-head PR; an exhausted phase opens an explicitly uncertified draft. The canary remains red until a certified PR merges, and changed pins are never pushed directly to `main`. Unchanged scheduled and forced certifications remain read-only and do not invoke the agent. Runner reads its pre-warmed HF cache over NFS (`HF_CACHE` + `HF_HUB_OFFLINE=1` in the runner `.env`; no `flock` on NFS, so the runner never downloads) | + +The changed-pin canary wrapper owns the target-pin transition: it writes the +sole upstream selector, `third_party/llama.cpp/upstream.txt`, prepares through +the checked-in `pinned` selector, and verifies the prepared-upstream stamp +before build or certification. Publication happens once at terminal state. +Scheduled changed-pin runs first query open `llama-canary/repair-*` PR bodies +and skip the expensive state machine when one records the exact candidate SHA. +The wrapper validates its runner paths, model cache, repository identity, and +repair token before phase execution; a failed preflight is reported without +claiming that a terminal PR exists. Git publication uses an environment-sourced +askpass helper so the repair PAT never appears in the push URL or process +arguments. Terminal PR bodies include the generated upstream diffstat and +commit summary, and changed-pin evidence has a distinct artifact name from the +unchanged-pin battery upload. Scheduled coverage details: an unchanged-pin llama canary uses the bounded `nightly` cadence (Qwen3 dense, Falcon-H1, Qwen3Next, and Mamba). Changed pins -use `llama-bump`, and a forced dispatch uses `manual-full`. A mapped model-only -upstream bump may select affected families plus sentinels; all other bumps and -forced dispatches retain the complete supported-family certification described in the table. The +use the complete `llama-bump` cohort, and a forced dispatch of the unchanged +pin uses `manual-full`. Both latter paths retain the complete supported-family +certification described in the table. The competitive benchmark can optionally download exact-cohort history from `MESH_PERFORMANCE_HISTORY_DATASET`, validate the checked-in schema, report regression candidates, and append one immutable run shard using diff --git a/ci/llama-canary/agent-repair-prompt.md b/ci/llama-canary/agent-repair-prompt.md index e6d370aeff..5cea1e3a62 100644 --- a/ci/llama-canary/agent-repair-prompt.md +++ b/ci/llama-canary/agent-repair-prompt.md @@ -1,10 +1,11 @@ -# llama.cpp canary patch-queue repair runbook (agent instructions) +# llama.cpp changed-pin canary repair runbook (agent instructions) You are running on the `family-certify` self-hosted runner inside a mesh-llm -checkout. The nightly llama-upstream canary either failed to apply our patch -queue in `third_party/llama.cpp/patches/` onto the new upstream pin -(patch-queue mode) or applied the queue but failed a certification lane -(battery mode). Your job: +checkout. The deterministic wrapper owns one +`prepare -> build -> certify -> publish` state machine for the candidate SHA in +`.deps/llama-canary-target-sha`. It has handed you one failed phase to repair. +Keep all work in the current checkout and leave commits, branches, pushes, PRs, +and comments to the wrapper. **Before touching the queue, read the repo skills and follow them:** `.agents/skills/llama-patch-changes/SKILL.md` (queue edits, upstream pin, @@ -12,10 +13,13 @@ prepare/build flow, patch ownership boundaries) and, when a patch changes the stage ABI, `.agents/skills/llama-stage-patch-changes/SKILL.md`. The boundaries in those skills are hard requirements for this repair, not suggestions. -1. **Reproduce.** Run `scripts/prepare-llama.sh "$(cat .deps/llama-canary-target-sha)"` - and capture which patch fails to apply (`git -C .deps/llama.cpp am --3way ...`). - A `.git/rebase-apply` state may be left behind; use `git am --show-current-patch` - and `git am --3way --continue`/`--abort` to inspect the conflict. +1. **Reproduce the failed phase.** The wrapper has already written the candidate + to `third_party/llama.cpp/upstream.txt`; prepare it with + `scripts/prepare-llama.sh pinned`. Inspect the supplied failure tail and run + only the focused build or certification commands needed to identify the root + cause. If patch application left `.git/rebase-apply`, use + `git am --show-current-patch` and `git am --3way --continue`/`--abort` to + inspect the conflict. 2. **Fix the queue — follow `llama-patch-changes`, do not loop on `git am`.** If a patch fails to apply, `git am --3way` retry alone is not an acceptable @@ -36,32 +40,25 @@ in those skills are hard requirements for this repair, not suggestions. irregular builder remains unchanged with the rewriter's precise `unsupported_shape` reason until a sound general rule exists. -3. **Build.** `scripts/build-llama.sh` then - `cargo check -p skippy-ffi -p skippy-runtime -p skippy-server`. +3. **Use focused verification while repairing.** The wrapper restarts from + prepare after every agent turn, runs the complete patched llama.cpp and Rust + build gates, and only then runs certification. Do not spend the remaining + wrapper deadline duplicating the complete battery unless the failure itself + requires a focused battery reproduction. -4. **Certify.** `scripts/skippy-family-battery.sh --skip-build`. - All lanes must pass. Do not weaken a failing lane; if a model is genuinely - broken by upstream, revert to fixing our patches or flag it in the PR body. - The wrapper re-runs the battery itself after your turn; if lanes fail you - will get the failure output in a follow-up repair turn — the loop only - ends when the wrapper's own battery run passes. +4. **Preserve every gate.** Do not weaken, skip, narrow, or mark a failing lane + unsupported to make the run green. Repair the patch queue, ABI mirrors, + manifests, or runtime code that owns the failure. The loop ends only when + the wrapper's own complete certification passes or its phase turn/time bound + is exhausted. -5. **Commit locally; the wrapper owns the PR.** Work on branch - `llama-canary/patch-queue-fix`. Commit the patch-queue changes with a - `fix(llama): rebase patch queue onto upstream ` message. You - have no GitHub credentials: the deterministic wrapper that drives you - keeps the repair local while repair and certification are active, then - commits any remaining work, pushes the branch, and creates/updates the - repair PR only at terminal success or failure. The wrapper separately asks - you to write the full PR - description (key upstream changes, how the patch queue evolved, risks for - reviewers) — when that turn arrives, write the finished Markdown to the - file it names and touch nothing else. After the wrapper's own battery run - passes, a separate review agent — not you — gets one fresh-context turn - to review the certified repair and fix any dropped intent or rebase - leftovers it finds; its changes land as their own `review(llama):` - commit locally and must pass the complete battery again before the wrapper - publishes the branch and PR. +5. **Leave the result local.** Do not switch or create a branch in the mesh-llm + checkout, commit there, push, open or edit a PR, comment on GitHub, or use + GitHub credentials. Temporary llama.cpp reconstruction branches and + worktrees required by the patch-queue skill remain local. The wrapper + commits the final mesh-llm tree, publishes one run-specific branch, + generates the PR body with the upstream summary, and opens either an exact + certified PR or an uncertified draft at terminal failure. Notes: - Models come from the runner's pre-warmed HF cache (`HF_CACHE`); `hf download` diff --git a/scripts/llama-canary-agent-repair.sh b/scripts/llama-canary-agent-repair.sh index 6d8b5c4966..1d549bd63e 100755 --- a/scripts/llama-canary-agent-repair.sh +++ b/scripts/llama-canary-agent-repair.sh @@ -1,256 +1,191 @@ #!/usr/bin/env bash set -euo pipefail -# llama-upstream canary agent repair (issue #1434; wired into -# llama-upstream-canary.yml for both patch-queue apply failures and family -# battery failures). +# Deterministic llama.cpp canary state machine for changed upstream pins. # -# Usage: llama-canary-agent-repair.sh [upstream-sha] -# mode: patch-queue - prepare-llama.sh failed to apply the patch queue -# onto the new upstream; the agent rebases the queue. -# mode: battery - the patch queue applied but the family battery -# failed; the agent fixes the root cause. -# upstream-sha: 40-hex llama.cpp commit, preferentially passed via the -# UPSTREAM_SHA_INPUT environment variable (callers must -# never interpolate untrusted dispatch values into shell). -# When omitted, resolves master via git ls-remote. "latest" -# is also accepted. Battery-mode evidence: when the workflow -# tees its battery log to $BATTERY_LOG, it is reused instead -# of re-running the battery before the first repair turn. +# Usage: llama-canary-agent-repair.sh [upstream-sha] # -# Drives a non-interactive `opencode` agent (model: -# zai-coding-plan/glm-5.3-flash by default) to repair, then the wrapper -# itself re-runs the certification battery. If it fails, each failure gets -# its own opencode repair turn (with the battery failure summary in the -# prompt) followed by a recertify, up to CANARY_REPAIR_MAX_TURNS (default 2) -# repair turns. The script only succeeds when the battery actually passes -# on this runner. -# -# PR guarantee: whatever the outcome, the wrapper (not the agent) publishes -# $BRANCH and creates or updates its repair PR only after the repair loop has -# reached a terminal certified or failed result. The PR description is drafted -# locally before certification (upstream changes, patch-queue evolution, risks) -# with a deterministic fallback body. After a GREEN battery exactly one more -# agent turn runs: a fresh-context review of the certified repair that may -# modify the tree (dropped patch intent, rebase leftovers, ABI mirror drift). -# Any review changes must pass the complete battery again before the terminal -# branch/PR publication. The review turn is fail-open only when it leaves the -# candidate tree unchanged. -# -# Credential split: the agent never sees a GitHub token — CANARY_REPAIR_TOKEN -# is stripped from its environment, and only the deterministic wrapper -# performs git pushes, PR creation, PR edits, and comments with the token -# scoped to individual commands. The wrapper — never the agent — commits the -# certified tree, pushes it, and verifies the repair PR head equals the -# certified commit before reporting success. -# -# Credentials: pushes/PRs use $CANARY_REPAIR_TOKEN (fine-grained PAT with -# Contents+PR write; the canary job itself stays contents: read). The agent -# needs OPENCODE_API_KEY/NEMOTRON_API_KEY or an `opencode auth login` -# profile on the runner. +# The wrapper owns prepare -> build -> certify -> publish. An agent may repair +# a failed phase, but it never decides whether a gate passed and never receives +# repository-write credentials. Every agent edit sends the candidate back +# through prepare and the complete build before another certification attempt. +# The branch and PR appear once, after success or a bounded terminal failure. ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -MODE="${1:?usage: llama-canary-agent-repair.sh [upstream-sha]}" -case "$MODE" in - patch-queue | battery) ;; - *) - echo "unknown repair mode: $MODE (expected patch-queue or battery)" >&2 - exit 1 - ;; -esac - -UPSTREAM_SHA="${2:-${UPSTREAM_SHA_INPUT:-latest}}" +UPSTREAM_SHA="${1:-${UPSTREAM_SHA_INPUT:-latest}}" if [[ "$UPSTREAM_SHA" == "latest" || -z "$UPSTREAM_SHA" ]]; then UPSTREAM_SHA="$(git ls-remote https://github.com/ggml-org/llama.cpp.git master | awk '{print $1}')" fi if [[ ! "$UPSTREAM_SHA" =~ ^[0-9a-f]{40}$ ]]; then - echo "refusing to repair against a non-40-hex upstream SHA: $UPSTREAM_SHA" >&2 + echo "refusing to run the canary against a non-40-hex upstream SHA: $UPSTREAM_SHA" >&2 exit 1 fi -OLD_SHA="$(tr -d '[:space:]' < "$ROOT/third_party/llama.cpp/upstream.txt")" +cd "$ROOT" + +OLD_SHA="$(tr -d '[:space:]' < third_party/llama.cpp/upstream.txt)" +PIN_FILE="$ROOT/third_party/llama.cpp/upstream.txt" AGENT_MODEL="${CANARY_AGENT_MODEL:-zai-coding-plan/glm-5.3-flash}" MAX_REPAIR_TURNS="${CANARY_REPAIR_MAX_TURNS:-2}" -BRANCH="llama-canary/patch-queue-fix" -BATTERY_LOG="$ROOT/.deps/llama-canary-repair-battery.log" -PIN_FILE="$ROOT/third_party/llama.cpp/upstream.txt" +REPAIR_BUDGET_SECONDS="${CANARY_REPAIR_BUDGET_SECONDS:-41400}" +PUBLISH_RESERVE_SECONDS="${CANARY_PUBLISH_RESERVE_SECONDS:-1800}" +RUN_ID="${GITHUB_RUN_ID:-manual-$(date +%s)}" +RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" +RUN_KEY="${RUN_ID}-${RUN_ATTEMPT}" +BRANCH="llama-canary/repair-${RUN_KEY}-${UPSTREAM_SHA:0:10}" +STATE_DIR="$ROOT/.deps/llama-canary-state-${RUN_KEY}" +TARGET_SHA_FILE="$ROOT/.deps/llama-canary-target-sha" +PREPARE_LOG="$STATE_DIR/prepare.log" +BUILD_LOG="$STATE_DIR/build.log" +CERTIFY_LOG="$STATE_DIR/certify.log" +PR_BODY="$STATE_DIR/pr-body.md" +UPSTREAM_SUMMARY="$STATE_DIR/upstream-summary.md" +GIT_ASKPASS_SCRIPT="$STATE_DIR/git-askpass.sh" +FAMILY_BATTERY_RUN_ID="${FAMILY_BATTERY_RUN_ID:-${RUN_KEY}}" +PLAN_PATH="$ROOT/target/family-battery/$FAMILY_BATTERY_RUN_ID/policy-plan.json" +STARTED_AT="$(date +%s)" +DEADLINE_AT="$((STARTED_AT + REPAIR_BUDGET_SECONDS))" +PUBLISHED_SHA="" +CERTIFIED_SHA="" +FAILED_PHASE="" +PREPARE_REPAIR_TURNS=0 +BUILD_REPAIR_TURNS=0 +CERTIFY_REPAIR_TURNS=0 + +if [[ ! "$MAX_REPAIR_TURNS" =~ ^[0-9]+$ ]]; then + echo "CANARY_REPAIR_MAX_TURNS must be a non-negative integer" >&2 + exit 1 +fi +if [[ ! "$REPAIR_BUDGET_SECONDS" =~ ^[0-9]+$ || ! "$PUBLISH_RESERVE_SECONDS" =~ ^[0-9]+$ ]] \ + || (( REPAIR_BUDGET_SECONDS <= PUBLISH_RESERVE_SECONDS )); then + echo "the canary budget must be numeric and exceed the publication reserve" >&2 + exit 1 +fi +for required_name in LLAMA_STAGE_BUILD_DIR HF_CACHE GITHUB_REPOSITORY; do + if [[ -z "${!required_name:-}" ]]; then + echo "${required_name} is not set; cannot run the changed-pin canary" >&2 + exit 1 + fi +done +if [[ -z "${CANARY_REPAIR_TOKEN:-}" ]]; then + echo "CANARY_REPAIR_TOKEN is not set; cannot publish the terminal canary PR" >&2 + exit 1 +fi -mkdir -p "$ROOT/.deps" -echo "$UPSTREAM_SHA" > "$ROOT/.deps/llama-canary-target-sha" +mkdir -p "$STATE_DIR" "$(dirname "$PLAN_PATH")" +rm -f "$PREPARE_LOG" "$BUILD_LOG" "$CERTIFY_LOG" "$PR_BODY" "$UPSTREAM_SUMMARY" +printf '%s\n' "$UPSTREAM_SHA" > "$TARGET_SHA_FILE" +cat > "$GIT_ASKPASS_SCRIPT" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + Username*) printf '%s\n' 'x-access-token' ;; + Password*) printf '%s\n' "${CANARY_REPAIR_TOKEN:?}" ;; + *) exit 1 ;; +esac +EOF +chmod 700 "$GIT_ASKPASS_SCRIPT" if ! command -v opencode >/dev/null 2>&1; then echo "opencode CLI not found on runner; install opencode-ai on the family-certify image" >&2 exit 1 fi -# Agent credentials: either an explicit API key env var, or an opencode CLI -# that has been logged in on the runner (`opencode auth login`), which -# `opencode run` picks up from its own auth store. if [[ -z "${OPENCODE_API_KEY:-}" && -z "${NEMOTRON_API_KEY:-}" ]]; then - if [[ ! -s "${HOME}/.local/share/opencode/auth.json" ]] && ! opencode auth list 2>/dev/null | grep -Eq '[1-9][0-9]* credentials'; then + if [[ ! -s "${HOME}/.local/share/opencode/auth.json" ]] \ + && ! opencode auth list 2>/dev/null | grep -Eq '[1-9][0-9]* credentials'; then echo "no agent credentials: set OPENCODE_API_KEY/NEMOTRON_API_KEY or run 'opencode auth login' on the runner" >&2 exit 1 fi fi -# The canary job itself is read-only; the repair branch push and PR need the -# dedicated fine-grained token. The token is never exported into the process -# environment — every GitHub mutation is routed through gh_repair(), which -# scopes it to that single command — so agent turns (and anything they spawn) -# never inherit repository-write credentials. -if [[ -z "${CANARY_REPAIR_TOKEN:-}" ]]; then - echo "CANARY_REPAIR_TOKEN is not set; cannot push the repair branch or open the repair PR" >&2 - exit 1 -fi - gh_repair() { - # Deterministic GitHub mutations only. The write PAT is scoped to this one - # command; it is deliberately absent from the ambient environment. GH_TOKEN="$CANARY_REPAIR_TOKEN" "$@" } check_repair_token_permissions() { - # Preflight (issue #1434 follow-up): fail in seconds when the identity behind - # CANARY_REPAIR_TOKEN cannot actually write to this repository, instead of - # discovering it via a git 403 after hours of repair work (seen live: a - # fine-grained PAT whose account HAD repo write, but whose token lacked the - # Contents: write permission). The REST permissions object reflects the - # ACCOUNT's access, not the token's fine-grained scope, so reading - # .permissions is not sufficient — the check probes the actual capability by - # creating and deleting a temporary ref, which requires exactly the - # Contents: write permission a push needs. The token is used only in scoped - # single commands, never exported, and never echoed. - local login default_branch head_sha probe_ref - if ! login="$(gh_repair gh api user --jq .login 2>/dev/null)"; then - echo "preflight: CANARY_REPAIR_TOKEN does not authenticate (gh api user failed); check the secret value" >&2 + local login default_branch head_sha probe_branch probe_ref + login="$(gh_repair gh api user --jq .login 2>/dev/null)" || { + echo "preflight: CANARY_REPAIR_TOKEN does not authenticate" >&2 return 1 - fi + } default_branch="$(gh_repair gh api "repos/${GITHUB_REPOSITORY:?}" --jq .default_branch 2>/dev/null)" - if [[ -z "$default_branch" ]]; then - echo "preflight: could not read ${GITHUB_REPOSITORY} with the repair token; grant the PAT access to this repository" >&2 - return 1 - fi head_sha="$(gh_repair gh api "repos/${GITHUB_REPOSITORY:?}/branches/${default_branch}" --jq .commit.sha 2>/dev/null)" - if [[ -z "$head_sha" ]]; then - echo "preflight: could not resolve ${default_branch} on ${GITHUB_REPOSITORY} with the repair token" >&2 - return 1 - fi - probe_ref="refs/heads/canary-repair-token-preflight" + probe_branch="canary-repair-token-preflight-${RUN_KEY}" + probe_ref="refs/heads/${probe_branch}" if ! gh_repair gh api --method POST "repos/${GITHUB_REPOSITORY:?}/git/refs" \ -f ref="$probe_ref" -f sha="$head_sha" >/dev/null 2>&1; then - echo "preflight: identity '${login}' cannot write refs on ${GITHUB_REPOSITORY}: the fine-grained PAT must include this repository with Contents: Read and write (account-level write is not enough). Edit the PAT's permissions or mint one with Contents: RW, then re-save CANARY_REPAIR_TOKEN." >&2 + echo "preflight: identity '${login}' cannot write refs on ${GITHUB_REPOSITORY}; CANARY_REPAIR_TOKEN needs Contents: Read and write" >&2 return 1 fi - # The delete URL uses the branch name only (slashes percent-encoded); the - # refs/ prefix is part of the path, not the ref identifier. if ! gh_repair gh api --method DELETE \ - "repos/${GITHUB_REPOSITORY:?}/git/refs/heads%2Fcanary-repair-token-preflight" >/dev/null 2>&1; then - echo "preflight: WARNING: write probe succeeded but the temporary ref ${probe_ref} could not be deleted; delete it manually" >&2 + "repos/${GITHUB_REPOSITORY:?}/git/refs/heads%2F${probe_branch}" >/dev/null 2>&1; then + echo "preflight: WARNING: could not delete temporary ref ${probe_ref}" >&2 fi echo "preflight: repair token identity '${login}' verified read+write on ${GITHUB_REPOSITORY}" } -cd "$ROOT" +redact_token() { + python3 -c 'import os, sys; token = os.environ["CANARY_REPAIR_TOKEN"]; sys.stdout.write(sys.stdin.read().replace(token, "***redacted***"))' +} + +remaining_work_seconds() { + local remaining + remaining="$((DEADLINE_AT - $(date +%s) - PUBLISH_RESERVE_SECONDS))" + (( remaining > 0 )) || return 1 + printf '%s\n' "$remaining" +} + +run_bounded() { + local label="$1" seconds + shift + if ! seconds="$(remaining_work_seconds)"; then + echo "$label cannot start: internal canary deadline reached; publication reserve is active" >&2 + return 124 + fi + python3 scripts/run-command-with-timeout.py \ + --seconds "$seconds" --label "$label" -- "$@" +} + +run_logged() { + local label="$1" log="$2" + shift 2 + run_bounded "$label" "$@" > >(tee -a "$log") 2>&1 +} -# Preflight the repair token before any repair work: a permission gap here -# fails the run in seconds with the exact fix, instead of surfacing as a git -# 403 after a potentially hours-long certified repair (seen live, run 33151501701). check_repair_token_permissions -# Run-scope the persistent-runner artifacts before anything else can fail and -# leave a previous run's state behind. The PR-body file is always cleared. The -# battery evidence log is cleared only in patch-queue mode: in battery mode it -# holds THIS run's workflow battery output (teed by the workflow immediately -# before invoking this script) and must survive to seed the first repair turn. -if [[ "$MODE" == "patch-queue" ]]; then - rm -f "$BATTERY_LOG" -fi -rm -f "$ROOT/.deps/llama-canary-pr-body.md" -rm -f "$ROOT/.deps/llama-canary-review-report.md" - -# Repair turns routinely build scratch worktrees under /tmp (e.g. -# /tmp/llama-old-pin, /tmp/llama-repair). On this persistent runner those -# directories and their registrations in .deps/llama.cpp/.git/worktrees -# survive across runs, and a later `git worktree add` for the same path -# fails ("missing but already registered" / stale admin files) — the agent -# treats that as fatal and ends its turn early (live: run 33158798988 -# aborted at `rm -rf /tmp/llama-old-pin && git worktree add ...`). Prune -# stale registrations and clear the known scratch paths up front. +# Persistent runners retain nested llama.cpp worktree registrations and /tmp +# checkouts. Remove only the known canary scratch state before agent turns. git -C "$ROOT/.deps/llama.cpp" worktree prune >/dev/null 2>&1 || true rm -rf /tmp/llama-old-pin /tmp/llama-repair /tmp/llama-repair-* 2>/dev/null || true agent_turn() { - # Non-fatal: a crashed agent turn must not skip PR reporting. The model - # runs without any GitHub token: push/PR/comment mutations are wrapper-only. - # A heartbeat monitor prints elapsed time every 10 minutes so multi-hour - # repair turns show progress in the Actions log instead of looking stuck. - # It also reports the newest file modification under the llama.cpp worktree, - # so watchers can tell "agent is editing" from "turn is hung" without - # runner access. - local prompt="$1" started heartbeat_pid + local prompt="$1" started heartbeat_pid status started="$(date +%s)" - # Job control makes the heartbeat its own process group (portable on both - # the macOS family-certify runner and Linux CI); env -i keeps the monitor - # credential-free. GNU find's printf action and util-linux's session - # utility are not portable to macOS, hence the plain find and the - # set -m process group. set -m - # shellcheck disable=SC2016 # heartbeat script takes its inputs as $1/$2 + # shellcheck disable=SC2016 env -i PATH="$PATH" bash -c ' ROOT="$1" started="$2" while sleep 600; do - newest="$(find "$ROOT/.deps/llama.cpp" -type f -newer "$ROOT/.deps/llama-canary-target-sha" -print -quit 2>/dev/null || true)" - printf "heartbeat: agent turn running for %dm; recent worktree activity: %s\n" \ + newest="$(find "$ROOT/.deps/llama.cpp" -type f -newer "$ROOT/third_party/llama.cpp/upstream.txt" -print -quit 2>/dev/null || true)" + printf "heartbeat: agent repair running for %dm; recent worktree activity: %s\n" \ "$(( ($(date +%s) - started) / 60 ))" "${newest:-none observed yet}" done ' heartbeat "$ROOT" "$started" & heartbeat_pid=$! set +m - # --auto: auto-approve opencode's permission prompts. The sandbox auto- - # REJECTS out-of-workspace writes (e.g. scratch dirs under /tmp) in non- - # interactive mode, and a rejection terminates the whole turn — every - # short-turn failure so far traces to this (live: run 33160131810 aborted - # on "external_directory (/tmp/opencode/*); auto-rejecting"). The agent - # runs with all GitHub tokens stripped and only the wrapper holds write - # credentials, so auto-approval inside this sandbox is safe. - env -u GH_TOKEN -u GITHUB_TOKEN -u CANARY_REPAIR_TOKEN \ - opencode run --auto --model "$AGENT_MODEL" "$prompt" \ - || echo "warning: opencode turn exited non-zero" >&2 + set +e + run_bounded "agent repair turn" env \ + -u GH_TOKEN -u GITHUB_TOKEN -u CANARY_REPAIR_TOKEN \ + opencode run --auto --model "$AGENT_MODEL" "$prompt" + status=$? + set -e kill -- "-$heartbeat_pid" 2>/dev/null || kill "$heartbeat_pid" 2>/dev/null || true wait "$heartbeat_pid" 2>/dev/null || true -} - -battery_summary() { - # Last 80 lines of the most recent battery evidence — either this run's - # workflow-teed log (battery mode) or the wrapper's own certification run — - # enough to name the failing family/split lanes without flooding the agent - # prompt. - local log="$1" - if [[ ! -s "$log" ]]; then - echo "(no battery output captured; see the canary run log)" - return 0 - fi - tail -n 80 "$log" -} - -current_pr() { - gh_repair gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true -} - -# Repair remote: the write PAT is embedded in the push URL (never echoed) and -# exists only for the lifetime of the single git push command. -repair_remote() { - echo "https://x-access-token:${CANARY_REPAIR_TOKEN}@github.com/${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set}.git" -} - -redact_token() { - # Strip the repair token from anything that might reach the log. - sed "s/${CANARY_REPAIR_TOKEN}/***redacted***/g" + return "$status" } write_repair_pin() { - # Pin ownership is deterministic wrapper work, never agent work. The repair - # branch must select the same upstream that its queue and battery certify. scripts/update-llama-pin.sh "$UPSTREAM_SHA" } @@ -258,424 +193,285 @@ verify_repair_pin() { local pin pin="$(tr -d '[:space:]' < "$PIN_FILE")" if [[ "$pin" != "$UPSTREAM_SHA" ]]; then - echo "repair pin does not select certified upstream $UPSTREAM_SHA (upstream.txt=$pin)" >&2 + echo "candidate pin is $pin, expected $UPSTREAM_SHA" >&2 return 1 fi } -prepare_repair_target() { +run_prepare() { local prepared_upstream - write_repair_pin || return 1 - verify_repair_pin || return 1 - scripts/prepare-llama.sh pinned || return 1 + : > "$PREPARE_LOG" + echo "state-machine phase: prepare" | tee -a "$PREPARE_LOG" + write_repair_pin >>"$PREPARE_LOG" 2>&1 || return 1 + verify_repair_pin >>"$PREPARE_LOG" 2>&1 || return 1 + run_logged "apply llama.cpp patch queue" "$PREPARE_LOG" \ + scripts/prepare-llama.sh pinned || return 1 prepared_upstream="$(tr -d '[:space:]' < "$ROOT/.deps/llama.cpp/.mesh-llm-upstream-sha")" if [[ "$prepared_upstream" != "$UPSTREAM_SHA" ]]; then - echo "pinned repair prepared upstream $prepared_upstream, expected $UPSTREAM_SHA" >&2 + echo "prepared upstream is $prepared_upstream, expected $UPSTREAM_SHA" | tee -a "$PREPARE_LOG" >&2 + return 1 + fi +} + +run_full_build() { + local archive arches + : > "$BUILD_LOG" + echo "state-machine phase: build" | tee -a "$BUILD_LOG" + run_logged "complete patched llama.cpp build" "$BUILD_LOG" env \ + LLAMA_STAGE_UPSTREAM_TESTS=ON uv run --no-project --with jinja2==3.1.6 -- \ + arch -arm64 bash scripts/build-llama.sh -DCMAKE_OSX_ARCHITECTURES=arm64 \ + || return 1 + archive="$LLAMA_STAGE_BUILD_DIR/src/libllama.a" + arches="$(lipo -archs "$archive" 2>/dev/null || true)" + if [[ "$arches" != "arm64" ]]; then + echo "candidate native archive must be arm64, got: ${arches:-missing}" | tee -a "$BUILD_LOG" >&2 return 1 fi + run_logged "generated model-family patch check" "$BUILD_LOG" \ + scripts/check-skippy-generated-family-patch.sh || return 1 + run_logged "stage runtime crate build" "$BUILD_LOG" \ + cargo build -p skippy-runtime -p skippy-server -p skippy-model-package -p skippy-correctness \ + || return 1 + if [[ "${LLAMA_UPSTREAM_CANARY_SMOKE:-1}" != "0" \ + && "${LLAMA_UPSTREAM_CANARY_SMOKE:-1}" != "false" ]]; then + run_logged "Skippy smoke tests" "$BUILD_LOG" scripts/skippy-ci-smoke.sh || return 1 + fi +} + +run_certification() { + : > "$CERTIFY_LOG" + echo "state-machine phase: certify" | tee -a "$CERTIFY_LOG" + run_logged "parity manifest validation" "$CERTIFY_LOG" \ + python3 scripts/skippy-llama-parity.py --llama-src .deps/llama.cpp validate \ + || return 1 + run_logged "full family certification plan" "$CERTIFY_LOG" \ + python3 scripts/plan-family-battery.py \ + --manifest ci/llama-canary/family-certified.json \ + --cadence llama-bump \ + --shard-count 1 \ + --check-cache \ + --cache-root "$HF_CACHE" \ + --output "$PLAN_PATH" \ + || return 1 + if [[ "${LLAMA_UPSTREAM_CANARY_SMOKE:-1}" != "0" \ + && "${LLAMA_UPSTREAM_CANARY_SMOKE:-1}" != "false" ]]; then + run_logged "live package-v2 matrix" "$CERTIFY_LOG" env \ + FAMILY_BATTERY_RUN_ID="$FAMILY_BATTERY_RUN_ID" \ + SKIPPY_CANARY_LIVE_MATRIX_BACKEND="${SKIPPY_CANARY_LIVE_MATRIX_BACKEND:-metal}" \ + SKIPPY_CANARY_LIVE_MATRIX_ROOT="$ROOT/target/family-battery/$FAMILY_BATTERY_RUN_ID" \ + scripts/skippy-canary-live-matrix.sh --prepare || return 1 + fi + run_logged "full supported-family certification" "$CERTIFY_LOG" env \ + FAMILY_BATTERY_RUN_ID="$FAMILY_BATTERY_RUN_ID" \ + scripts/skippy-family-battery.sh --skip-build --plan "$PLAN_PATH" +} + +phase_log() { + case "$1" in + prepare) printf '%s\n' "$PREPARE_LOG" ;; + build) printf '%s\n' "$BUILD_LOG" ;; + certify) printf '%s\n' "$CERTIFY_LOG" ;; + esac +} + +phase_turns() { + case "$1" in + prepare) printf '%s\n' "$PREPARE_REPAIR_TURNS" ;; + build) printf '%s\n' "$BUILD_REPAIR_TURNS" ;; + certify) printf '%s\n' "$CERTIFY_REPAIR_TURNS" ;; + esac +} + +increment_phase_turns() { + case "$1" in + prepare) PREPARE_REPAIR_TURNS=$((PREPARE_REPAIR_TURNS + 1)) ;; + build) BUILD_REPAIR_TURNS=$((BUILD_REPAIR_TURNS + 1)) ;; + certify) CERTIFY_REPAIR_TURNS=$((CERTIFY_REPAIR_TURNS + 1)) ;; + esac +} + +repair_prompt() { + local phase="$1" log turn + log="$(phase_log "$phase")" + turn="$(( $(phase_turns "$phase") + 1 ))" + printf 'The llama.cpp canary %s phase failed at upstream %s (repair turn %s of %s for this phase). + +Read ci/llama-canary/agent-repair-prompt.md and the repository instructions it names. Follow that runbook for this wrapper-owned prepare -> build -> certify -> publish state machine. Fix the root cause minimally. Do not weaken, skip, or narrow any gate. Use focused checks while repairing; the deterministic wrapper will restart at prepare, run the complete build, and run the full supported-family certification before it can publish success. Leave changes local. Do not push, open a PR, or use GitHub credentials. + +Failure evidence (tail): + +%s' "$phase" "$UPSTREAM_SHA" "$turn" "$MAX_REPAIR_TURNS" \ + "$(tail -n 100 "$log" 2>/dev/null || echo '(no phase output captured)')" +} + +current_pr() { + gh_repair gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true } -commit_repair_tree() { - # The wrapper — never the agent — owns the terminal branch commit. This may - # run before the post-green review so the reviewer sees a clean candidate; - # it remains local until publish_repair_branch is called at terminal status. +commit_terminal_tree() { local outcome="$1" git checkout -B "$BRANCH" - if [[ -n "$(git status --porcelain)" ]]; then - git add -A + git add -A + if ! git diff --cached --quiet; then if [[ "$outcome" == "certified" ]]; then - git commit -m "fix(llama): canary repair at upstream ${UPSTREAM_SHA:0:10}" \ - -m "Automated llama.cpp canary repair (mode: ${MODE}). Certified by the family battery on the family-certify runner." + git commit -m "fix(llama): certify upstream ${UPSTREAM_SHA:0:10}" \ + -m "Prepare, build, and run the full supported-family certification through the deterministic canary state machine." else - git commit -m "fix(llama): preserve failed canary repair at upstream ${UPSTREAM_SHA:0:10}" \ - -m "Automated llama.cpp canary repair (mode: ${MODE}) reached a terminal failure after repair and verification. See the repair PR for the failing evidence." + git commit -m "fix(llama): preserve failed canary at ${UPSTREAM_SHA:0:10}" \ + -m "Preserve the bounded terminal state from the ${FAILED_PHASE} phase for human diagnosis." fi fi } -publish_repair_branch() { - # The only branch publication point. It runs after the repair/certification - # loop reaches terminal success or failure, records the exact published SHA, - # and marks it certified only for the successful outcome. Force-push is - # intentional: the agent rebases the patch queue, so non-fast-forward updates - # are normal on this wrapper-owned branch. +publish_terminal_branch() { local outcome="$1" - commit_repair_tree "$outcome" + commit_terminal_tree "$outcome" PUBLISHED_SHA="$(git rev-parse HEAD)" if [[ "$outcome" == "certified" ]]; then CERTIFIED_SHA="$PUBLISHED_SHA" fi - # Push failures are almost always a token-identity permission gap (seen - # live: 403 "denied to i386" because the PAT account lacked repo write). - # Surface a precise, actionable message instead of a bare git error, and - # never leak the token in the hint. - if ! git push "$(repair_remote)" "+HEAD:refs/heads/${BRANCH}" 2> >(redact_token >&2); then - echo "ERROR: could not push ${BRANCH}. If git reported 403/denied above, the identity behind CANARY_REPAIR_TOKEN lacks write access to ${GITHUB_REPOSITORY:?}: grant that account Contents+Pull requests write (or mint the PAT from an account that has it) and update the secret." >&2 + if ! GIT_ASKPASS="$GIT_ASKPASS_SCRIPT" GIT_TERMINAL_PROMPT=0 \ + git push "https://github.com/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/${BRANCH}" 2> >(redact_token >&2); then + echo "ERROR: could not push ${BRANCH}; the identity behind CANARY_REPAIR_TOKEN needs Contents and pull-request write access" >&2 return 1 fi } -ensure_pr() { - # Wrapper-owned PR guarantee: if no open PR exists on $BRANCH (the branch - # was just pushed by publish_repair_branch), create one. If the branch has - # no diff against the base (agent produced nothing), fall back to an issue - # so the outcome is still visible to humans. - local pr title body - pr="$(current_pr)" - if [[ -n "$pr" ]]; then - printf '%s\n' "$pr" +write_upstream_summary() { + if SKIPPY_CI_SMOKE="${LLAMA_UPSTREAM_CANARY_SMOKE:-1}" \ + scripts/summarize-llama-upstream.sh "$OLD_SHA" "$UPSTREAM_SHA" "$ROOT/.deps/llama.cpp" \ + | awk 'BEGIN { include = 1 } /^## Validation$/ { include = 0 } include { print }' \ + > "$UPSTREAM_SUMMARY"; then return 0 fi - title="fix(llama): rebase patch queue onto upstream ${UPSTREAM_SHA:0:10}" - body="Automated canary repair PR for the llama.cpp patch queue at upstream ${UPSTREAM_SHA}." - if ! git diff --quiet origin/main..."$BRANCH" 2>/dev/null; then - if pr="$(gh_repair gh pr create --head "$BRANCH" --title "$title" --body "$body" 2>/dev/null \ - | grep -oE '[0-9]+$')"; then - printf '%s\n' "$pr" - return 0 - fi - fi - gh_repair gh issue create --title "llama canary repair needs human assistance (upstream ${UPSTREAM_SHA:0:10})" \ - --body "The canary repair loop could not open a PR on \`$BRANCH\` (branch missing or no diff). See the canary run log for the repair-loop outcome." \ - | grep -oE '[0-9]+$' || true - return 0 + printf '%s\n' \ + '## Upstream Summary' \ + '' \ + 'The automated upstream summary was unavailable; inspect the candidate pin and patch queue directly.' \ + > "$UPSTREAM_SUMMARY" } -verify_pr_head_is_certified() { - # The PR must carry exactly the final reviewed bytes that passed the battery. - local pr remote_head attempt - pr="$(current_pr)" - if [[ -z "$pr" ]]; then - echo "no repair PR to verify" >&2 - return 1 - fi - remote_head="$(gh_repair gh pr view "$pr" --json headRefOid --jq .headRefOid 2>/dev/null || true)" - for attempt in 1 2 3; do - if [[ "$remote_head" == "${CERTIFIED_SHA:?}" ]]; then - return 0 +write_pr_body() { + local outcome="$1" + write_upstream_summary + { + echo "Automated llama.cpp upstream canary for \`${UPSTREAM_SHA}\`." + echo + echo "- Previous pin: \`${OLD_SHA}\`" + echo "- Candidate pin: \`${UPSTREAM_SHA}\`" + echo "- Workflow run: \`${RUN_KEY}\`" + echo "- Terminal commit: \`${PUBLISHED_SHA}\`" + echo "- Repair turns: prepare=${PREPARE_REPAIR_TURNS}, build=${BUILD_REPAIR_TURNS}, certify=${CERTIFY_REPAIR_TURNS}" + echo + if [[ "$outcome" == "certified" ]]; then + echo "The wrapper applied the complete patch queue, completed the patched llama.cpp and Rust build gates, and passed the full supported-family certification on this exact commit." + else + echo "This draft preserves a bounded terminal failure in the **${FAILED_PHASE}** phase. It is not certified and is not eligible to merge until the failing gate is repaired and the complete state machine passes." fi - sleep "$attempt" - remote_head="$(gh_repair gh pr view "$pr" --json headRefOid --jq .headRefOid 2>/dev/null || true)" - done - echo "repair PR #${pr} head (${remote_head:-none}) does not match the final certified commit ${CERTIFIED_SHA}" >&2 - return 1 -} - -pr_comment() { - # Post a status comment on the repair PR; never fails the loop. - local body="$1" resource - resource="$(current_pr)" - [[ -n "$resource" ]] || resource="$(ensure_pr)" - [[ -n "$resource" ]] || return 0 - # ensure_pr returns an issue number when no PR exists; use the right command. - if gh_repair gh pr view "$resource" >/dev/null 2>&1; then - gh_repair gh pr comment "$resource" --body "$body" >/dev/null 2>&1 || true - else - gh_repair gh issue comment "$resource" --body "$body" >/dev/null 2>&1 || true - fi -} - -report_success() { - # Terminal green closeout. Repair, review, and any required recertification - # have completed before this function performs the first branch/PR mutation. - publish_repair_branch certified - # Ensure the PR exists BEFORE applying the body: on a first run no PR - # exists yet, ensure_pr creates it (with the generic body), and the agent's - # pre-certification draft is then applied on top (live: run 33163990453 - # pushed a certified branch and opened the PR via pr_comment's ensure_pr - # AFTER apply_pr_body had already no-op'd, so the PR kept the generic body - # and the agent's 103-line analysis was never shown). - ensure_pr >/dev/null - apply_pr_body - # The literal backticks around the certified SHA are Markdown, not command - # substitution. - # shellcheck disable=SC2016 - pr_comment "$(printf '**Family battery passed** after the agent repair at upstream %s.\nAll certification lanes green on the family-certify runner; certified commit: `%s`.%s%s' \ - "$UPSTREAM_SHA" "${CERTIFIED_SHA:?}" "${REVIEW_STATUS:-}" "${REVIEW_REPORT_TAIL:-}")" - verify_pr_head_is_certified + echo + echo "State machine: \`prepare -> build -> certify -> publish\`. Agent turns may edit local files, while the wrapper owns every gate and all GitHub mutations." + echo + cat "$UPSTREAM_SUMMARY" + } > "$PR_BODY" } -report_failure() { - # Terminal red closeout. Preserve the final attempted repair and its evidence - # only after all repair/verification work for this outcome has stopped. - local body="$1" - publish_repair_branch failed || echo "warning: could not publish failed repair branch" >&2 - ensure_pr >/dev/null - apply_pr_body - pr_comment "$body" -} - -draft_pr_body() { - # One agent turn drafts the pending repair PR description BEFORE - # certification: key upstream changes between the old pin and the repair - # target, how the patch queue evolved, risks, and validation. Runs strictly - # before any battery attempt it describes; a failed or empty turn falls back - # to the deterministic body in apply_pr_body. - local body_file - body_file="$ROOT/.deps/llama-canary-pr-body.md" - agent_turn "$(printf 'Write the description for the pending llama.cpp canary repair PR.\nAnalyze the llama.cpp changes between %s (old pinned upstream) and %s\n(repair target), summarize the key upstream changes, explain how the patch\nqueue in third_party/llama.cpp/patches/ evolved in this repair (per patch:\nwhat conflicted and how it was resolved), and identify risks for reviewers\n(including any ABI impact and any lane that is newly failing or excluded).\nWrite the finished Markdown description to %s using your file tools. Do not\nedit any other file. Note: you have no GitHub credentials; the wrapper keeps\nthe repair local until terminal certification or failure, then owns all pushes\nand PR updates.' \ - "${OLD_SHA:0:10}" "${UPSTREAM_SHA:0:10}" "$body_file")" -} - -apply_pr_body() { - # Publish the PR description from the pre-certification agent draft (or the - # deterministic fallback). No agent involvement: this may run after a green - # battery, so it must be token-only and deterministic. - local pr body_file +ensure_pr() { + local outcome="$1" pr title created + local -a create_args pr="$(current_pr)" - [[ -n "$pr" ]] || return 0 - body_file="$ROOT/.deps/llama-canary-pr-body.md" - if [[ ! -s "$body_file" ]]; then - { - echo "Automated canary repair at upstream ${UPSTREAM_SHA}." - echo - echo "- Old pinned upstream: ${OLD_SHA}" - echo "- Repair target upstream: ${UPSTREAM_SHA}" - echo "- Mode: ${MODE}" - echo - echo "The agent-written upstream/queue analysis was unavailable; reviewers" - echo "should diff the patch queue against main directly." - } > "$body_file" + if [[ -n "$pr" ]]; then + gh_repair gh pr edit "$pr" --body-file "$PR_BODY" >/dev/null + printf '%s\n' "$pr" + return 0 fi - gh_repair gh pr edit "$pr" --body-file "$body_file" >/dev/null 2>&1 || true -} - -run_battery() { - # Runs the certification battery; prints the summary line and returns the - # battery exit code. The build runs under `arch -arm64` mirroring the - # workflow's own build step: the family-certify job runs under Rosetta, and - # a plain build-llama.sh rebuild would reconfigure for x86_64, leaving - # arm64 Rust objects unable to link against x86_64 native archives (seen - # live in run 33140672269). An arm64 sanity check follows the build so a - # misconfigured toolchain fails loudly instead of as symbol errors. - prepare_repair_target || return 1 - # Battery-mode repairs still prove the complete pinned live matrix. New - # conventional model builders are handled by the source rewriter and the - # single generated family patch; the repair loop no longer selects one - # missing boundary-registration family or grows the model manifest. - if [[ "$MODE" == "battery" ]]; then - if ! python3 scripts/skippy-llama-parity.py --llama-src .deps/llama.cpp \ - validate >>"$BATTERY_LOG" 2>&1; then - tail -n 2 "$BATTERY_LOG" - echo "parity manifest validation failed; repair cannot certify" >&2 - return 1 - fi - export SKIPPY_CANARY_LIVE_MATRIX_BACKEND="${SKIPPY_CANARY_LIVE_MATRIX_BACKEND:-metal}" - if ! arch -arm64 scripts/skippy-canary-live-matrix.sh --prepare \ - >>"$BATTERY_LOG" 2>&1; then - tail -n 2 "$BATTERY_LOG" - echo "live package-v2 matrix failed; repair cannot certify" >&2 - return 1 - fi + if [[ "$outcome" == "certified" ]]; then + title="fix(llama): certify upstream ${UPSTREAM_SHA:0:10}" + create_args=() + else + title="draft(llama): failed canary at ${UPSTREAM_SHA:0:10}" + create_args=(--draft) fi - LLAMA_STAGE_UPSTREAM_TESTS=ON uv run --no-project --with jinja2==3.1.6 -- \ - arch -arm64 scripts/build-llama.sh \ - -DCMAKE_OSX_ARCHITECTURES=arm64 || return 1 - local archive - archive="${LLAMA_STAGE_BUILD_DIR:-}/src/libllama.a" - if [[ -n "${LLAMA_STAGE_BUILD_DIR:-}" && -f "$archive" ]] \ - && [[ "$(lipo -archs "$archive" 2>/dev/null)" != "arm64" ]]; then - echo "refusing to certify: native archive is not arm64: $(lipo -archs "$archive" 2>/dev/null)" >&2 + if ! created="$(gh_repair gh pr create --base main --head "$BRANCH" "${create_args[@]}" \ + --title "$title" --body-file "$PR_BODY" 2> >(redact_token >&2))"; then + echo "ERROR: could not create the terminal canary PR for ${BRANCH}" >&2 return 1 fi - if ! scripts/check-skippy-generated-family-patch.sh >>"$BATTERY_LOG" 2>&1; then - tail -n 5 "$BATTERY_LOG" - echo "generated model-family patch is stale or invalid; repair cannot certify" >&2 + if ! pr="$(printf '%s\n' "$created" | grep -oE '[0-9]+$')"; then + echo "ERROR: terminal canary PR creation returned no PR number for ${BRANCH}" >&2 return 1 fi - if scripts/skippy-family-battery.sh >"$BATTERY_LOG" 2>&1; then - tail -n 2 "$BATTERY_LOG" - return 0 - fi - tail -n 2 "$BATTERY_LOG" - return 1 + printf '%s\n' "$pr" } -post_green_review_turn() { - # Fresh-context review after a green battery (the change review asked for: - # even a certified repair PR gets an agent review that may modify it). - # Parity certification cannot see a rebase that silently drops a patch's - # intent (e.g. a conflict resolution that yields parity but deletes an - # upstream feature we had deliberately stopped deleting), so one review - # turn runs on a locally committed, certified candidate. It is explicitly - # told it did NOT author the repair. It may modify the tree — fix dropped intent, - # rebase leftovers, stale patch metadata, Rust ABI mirror drift — and its - # changes become a separate local `review(llama):` commit. The caller runs - # the complete battery again when the candidate changes, and only then may - # publish the branch and PR. A crashed or disabled review is fail-open only - # when the candidate tree remains unchanged. - local review_base - REVIEW_CHANGED=false - rm -f "$ROOT/.deps/llama-canary-review-report.md" - if [[ "${CANARY_AGENT_REVIEW:-true}" != "true" ]]; then - echo "post-green agent review disabled (CANARY_AGENT_REVIEW != true); skipping" - REVIEW_STATUS=" Post-green agent review skipped (disabled via CANARY_AGENT_REVIEW)." - return 0 - fi - echo "post-green agent review turn (fresh context)..." - review_base="$(git rev-parse HEAD)" - agent_turn "$(printf 'You are a DIFFERENT agent reviewing a completed llama.cpp canary repair — you did NOT write it. Everything below is already certified green by the family battery, so do not re-run it. - -Read the repair PR branch (HEAD of this checkout, branch %s): the patch queue in third_party/llama.cpp/patches/ and the commits since main, plus ci/llama-canary/agent-repair-prompt.md and the repo skills it names for patch-ownership boundaries. The repair rebased the queue onto upstream %s. - -Review the repair, not the upstream code. Certification parity cannot see semantic losses, so check: -1. Dropped intent: does any conflict resolution or regenerated patch silently stop doing what the old patch did (a deliberately-kept upstream feature accidentally deleted, a guard or accounting change quietly dropped)? -2. Rebase leftovers: conflict markers, duplicate patch fragments, hunks that now apply as no-ops, stale patch descriptions. -3. Patch hygiene: series ordering, patch subjects/bodies still matching content, no accidental upstream-code deletion (per the skills, patches must not delete upstream behavior we are not chartered to delete). -4. ABI mirrors: if any patch changed the stage ABI, did the Rust mirrors in crates/ track it (version + PREPARE_SCHEMA bumped together)? -5. Weakened lanes: any manifest, policy, or battery change that certifies less than before? - -If (and only if) you find a real defect, fix it minimally in the patch queue (or its Rust ABI mirror) and commit locally with message "review(llama): ". Never weaken a certification lane. The wrapper will run the complete certification battery again before it publishes any review changes. - -Write your review findings (verification steps, defects found, fixes made or recommended) to %s using your file tools. Then stop. You have no GitHub credentials — the wrapper owns all pushes and PR updates.' \ - "$BRANCH" "$UPSTREAM_SHA" "$ROOT/.deps/llama-canary-review-report.md")" \ - || echo "warning: post-green review turn exited non-zero; inspecting any partial changes" >&2 - # A review turn may edit the worktree, but it never owns the selected - # upstream. Restore and verify the pin before deciding whether the candidate - # changed. Even an agent crash with partial edits triggers recertification. - write_repair_pin || return 1 - verify_repair_pin || return 1 - if [[ "$(git rev-parse HEAD)" != "$review_base" || -n "$(git status --porcelain)" ]]; then - if [[ -n "$(git status --porcelain)" ]]; then - git add -A - if ! git diff --cached --quiet; then - git commit -m "review(llama): agent review fixes at upstream ${UPSTREAM_SHA:0:10}" \ - -m "Modifications from the post-certification agent review of the canary repair (see the repair PR comment for the review report)." - fi - fi - echo "post-green review made changes; final candidate must be recertified" - REVIEW_CHANGED=true - REVIEW_STATUS=" Post-green agent review made modifications, and the final reviewed tree passed a second complete certification before publication." - else - echo "post-green review found nothing to change; certified tree unchanged" - REVIEW_STATUS=" Post-green agent review found nothing to change; the certified tree stands." - fi - if [[ -s "$ROOT/.deps/llama-canary-review-report.md" ]]; then - # shellcheck disable=SC2016 # fenced backticks are intentional Markdown - REVIEW_REPORT_TAIL="$(printf '\n\n
Post-green agent review report (tail)\n\n```\n%s\n```\n\n
' \ - "$(tail -n 20 "$ROOT/.deps/llama-canary-review-report.md")")" - else - echo "post-green review produced no report" >&2 - REVIEW_REPORT_TAIL="" - if [[ "$REVIEW_CHANGED" == "false" ]]; then - REVIEW_STATUS=" Post-green agent review ran but produced no report; the certified tree is unchanged." - fi - fi +verify_pr_head() { + local expected="$1" pr remote_head attempt + pr="$(current_pr)" + [[ -n "$pr" ]] || { echo "terminal canary PR was not created" >&2; return 1; } + for attempt in 1 2 3; do + remote_head="$(gh_repair gh pr view "$pr" --json headRefOid --jq .headRefOid 2>/dev/null || true)" + [[ "$remote_head" == "$expected" ]] && return 0 + sleep "$attempt" + done + echo "canary PR #${pr} head (${remote_head:-none}) does not match published commit ${expected}" >&2 + return 1 } -certify_reviewed_repair() { - # Certify the repaired candidate, run the semantic review locally, and bind - # success to a second complete battery whenever that review changes bytes. - REVIEW_STATUS="" - REVIEW_REPORT_TAIL="" - run_battery || return 1 - commit_repair_tree certified - post_green_review_turn || return 1 - if [[ "$REVIEW_CHANGED" == "true" ]]; then - echo "post-green review changed the candidate; running final certification..." - run_battery || return 1 +report_terminal() { + local outcome="$1" pr comment + publish_terminal_branch "$outcome" + write_pr_body "$outcome" + pr="$(ensure_pr "$outcome")" + verify_pr_head "$PUBLISHED_SHA" + if [[ "$outcome" == "certified" ]]; then + comment="**Certified terminal state.** The exact PR head \`${CERTIFIED_SHA}\` passed prepare, the complete build, and the full supported-family certification." + else + comment="**Uncertified terminal state.** The internal deadline or repair-turn limit stopped the \`${FAILED_PHASE}\` phase. This draft preserves the final attempted bytes and must not merge until the complete state machine passes." fi - return 0 + gh_repair gh pr comment "$pr" --body "$comment" >/dev/null 2>&1 || true + echo "terminal canary PR #${pr}: ${outcome}; branch=${BRANCH}; head=${PUBLISHED_SHA}" } -repair_followup_prompt() { - # Shared prompt for every repair turn. In battery mode turn 1 this is - # seeded directly from the workflow's teed failure evidence (no battery - # re-run first); later turns carry the wrapper's own certification output. - # The agent has no GitHub credentials; the wrapper keeps all work local until - # terminal success or failure, then commits, pushes, and updates the PR. - printf 'The family certification battery failed after the patch-queue repair -at upstream %s (attempt %s of %s). You are working in this repository checkout. - -Read ci/llama-canary/agent-repair-prompt.md and the repo skills it names, then -fix the root cause — do not weaken a failing lane. If a model is genuinely -broken by upstream, fix our patches or flag it in the PR body. The failing -battery output (tail): - -%s - -Re-run scripts/skippy-family-battery.sh --skip-build yourself to confirm your -fix, and leave your work in the working tree or on local commits — the wrapper -will publish the branch and repair PR only after terminal certification or failure.' \ - "$UPSTREAM_SHA" "$1" "$MAX_REPAIR_TURNS" "$(battery_summary "$BATTERY_LOG")" -} +phase="prepare" +while true; do + phase_status=0 + # A function called on the left side of `||` inherits disabled errexit. + # Every fallible phase command therefore has an explicit `|| return 1`; + # the final command's status is the function status. This is load-bearing. + case "$phase" in + prepare) + run_prepare || phase_status=$? + [[ "$phase_status" -ne 0 ]] || { phase="build"; continue; } + ;; + build) + run_full_build || phase_status=$? + [[ "$phase_status" -ne 0 ]] || { phase="certify"; continue; } + ;; + certify) + run_certification || phase_status=$? + if [[ "$phase_status" -eq 0 ]]; then + report_terminal certified + exit 0 + fi + ;; + esac -if [[ "$MODE" == "patch-queue" ]]; then - agent_turn "$(printf 'The canary failed to apply the llama.cpp patch queue at upstream %s. -Read ci/llama-canary/agent-repair-prompt.md in this repo and follow it exactly. -Commit your work locally when done. You have no GitHub credentials — the -wrapper that invoked you keeps the repair local until terminal success or -failure, then owns all pushes and PR updates. The pending branch is %s.' \ - "$UPSTREAM_SHA" "$BRANCH")" - - echo "agent repair turn finished; verifying queue applies..." - if ! prepare_repair_target; then - report_failure "$(printf '**Repair stuck — needs human assistance.** The patch queue still does not apply through the checked-in pin at upstream %s after the agent repair turn (see the canary run log for the failing patch). The terminal agent work is preserved on this branch.' \ - "$UPSTREAM_SHA")" + FAILED_PHASE="$phase" + if ! remaining_work_seconds >/dev/null; then + echo "internal canary deadline reached after ${phase} failure; reserving time for terminal publication" >&2 + report_terminal failed exit 1 fi -else - # battery mode: the queue already applies and the workflow's own battery - # step just failed on this runner. Its evidence log (teed to - # $BATTERY_LOG by the workflow) seeds the first repair turn, so no build - # or battery run is repeated before the agent gets the failure output. - if ! prepare_repair_target; then - report_failure "$(printf '**Repair stuck — needs human assistance.** The battery-mode repair no longer applies through the checked-in pin at upstream %s. The terminal agent work is preserved on this branch.' \ - "$UPSTREAM_SHA")" + if (( $(phase_turns "$phase") >= MAX_REPAIR_TURNS )); then + echo "${phase} exhausted ${MAX_REPAIR_TURNS} repair turns" >&2 + report_terminal failed exit 1 fi - if [[ ! -s "$BATTERY_LOG" ]]; then - echo "battery mode: no workflow battery evidence at $BATTERY_LOG; running one diagnostic battery attempt..." >&2 - run_battery || true - else - echo "battery mode: reusing workflow battery evidence from $BATTERY_LOG" - fi -fi -# Draft locally before certification. No branch or PR mutation occurs until a -# terminal success or failure path calls report_success or report_failure. -draft_pr_body - -# Certify → repair → recertify loop. The wrapper — not the agent — decides -# when certification passes, so a lane failure can never be talked past. -# In battery mode the workflow's own battery step already failed on this -# runner (or the diagnostic attempt above did): iteration 1 is the repair -# turn seeded from that evidence, never another full build+battery run -# before the agent gets a chance to fix anything. -attempt=0 -while (( attempt < MAX_REPAIR_TURNS )); do - attempt=$((attempt + 1)) - if [[ "$MODE" == "battery" && "$attempt" -eq 1 ]]; then - echo "battery mode: repair turn 1 seeded from the workflow battery failure evidence" - else - echo "certification attempt $attempt..." - if certify_reviewed_repair; then - echo "family battery and post-green review certification passed; repair complete" - report_success - exit 0 - fi - fi - echo "family battery failed on repair turn $attempt; handing failures to the agent" - agent_turn "$(repair_followup_prompt "$attempt")" - echo "agent repair turn $attempt finished; verifying queue applies..." - if ! prepare_repair_target; then - report_failure "$(printf '**Repair stuck — needs human assistance.** The patch queue regressed or still does not apply through the checked-in pin at upstream %s after repair turn %s/%s. The terminal agent work is preserved on this branch; see the canary run log for the failing patch.' \ - "$UPSTREAM_SHA" "$attempt" "$MAX_REPAIR_TURNS")" - exit 1 - fi + prompt="$(repair_prompt "$phase")" + increment_phase_turns "$phase" + agent_turn "$prompt" || echo "warning: agent repair turn exited non-zero; wrapper will retry the gates" >&2 + # Any agent edit may affect the selected pin or patch queue. Restore the + # deterministic pin and restart at the first invalidated gate. + phase="prepare" done - -echo "final certification attempt..." -if certify_reviewed_repair; then - echo "family battery and post-green review certification passed; repair complete" - report_success - exit 0 -fi - -# The final status comment embeds a fenced battery tail; the literal -# backticks are intentional Markdown, not command substitution. -# shellcheck disable=SC2016 -report_failure "$(printf '**Repair stuck — needs human assistance.** The family battery is still failing after %s agent repair turns at upstream %s. The terminal agent work is preserved on this branch; the failing battery output (tail):\n\n```\n%s\n```' \ - "$MAX_REPAIR_TURNS" "$UPSTREAM_SHA" "$(battery_summary "$BATTERY_LOG")")" -echo "family battery still failing after $MAX_REPAIR_TURNS agent repair turns" >&2 -exit 1 diff --git a/scripts/tests/test_llama_canary_agent_repair_contract.py b/scripts/tests/test_llama_canary_agent_repair_contract.py index a425c9cb13..46b2d8f767 100644 --- a/scripts/tests/test_llama_canary_agent_repair_contract.py +++ b/scripts/tests/test_llama_canary_agent_repair_contract.py @@ -2,7 +2,6 @@ import importlib.util import os -import stat import subprocess import sys import tempfile @@ -10,399 +9,299 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -REPAIR = ROOT / "scripts" / "llama-canary-agent-repair.sh" +WRAPPER = ROOT / "scripts" / "llama-canary-agent-repair.sh" +RUNBOOK = ROOT / "ci" / "llama-canary" / "agent-repair-prompt.md" -class LlamaCanaryAgentRepairContractTests(unittest.TestCase): - """Behavioral contracts for the canary repair wrapper. +class LlamaCanaryStateMachineContractTests(unittest.TestCase): + def setUp(self) -> None: + self.wrapper = WRAPPER.read_text(encoding="utf-8") - The wrapper mediates between an untrusted model turn and repository-write - credentials. These tests pin the invariants the review demanded: the - agent never sees a GitHub token, the token never reaches the environment, - repair PR publication is terminal, dispatch SHAs are validated as 40-hex - before any use, battery-mode evidence is reused instead of re-running the - battery, and persistent runner state is cleared at the start of every run. - """ + def test_wrapper_has_one_ordered_state_machine(self) -> None: + main = self.wrapper[self.wrapper.index('phase="prepare"\nwhile true; do') :] + self.assertIn('phase="build"; continue', main) + self.assertIn('phase="certify"; continue', main) + self.assertIn("report_terminal certified", main) + self.assertIn('phase="prepare"\ndone', main) + self.assertNotIn("post_green", self.wrapper) + self.assertNotIn("patch-queue | battery", self.wrapper) - def test_agent_turns_strip_github_tokens_from_environment(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # The write PAT is never exported into the environment. - self.assertNotIn("export GH_TOKEN", wrapper) - # Agent turns explicitly strip every GitHub credential. - self.assertIn("env -u GH_TOKEN -u GITHUB_TOKEN -u CANARY_REPAIR_TOKEN", wrapper) - # Turns run with --auto: opencode's sandbox otherwise auto-rejects - # out-of-workspace scratch writes (/tmp) in non-interactive mode and - # the rejection kills the whole turn (live: run 33160131810). Safe - # because no GitHub credential is present in the agent environment. - self.assertIn('opencode run --auto --model "$AGENT_MODEL"', wrapper) - # GitHub mutations go through the token-scoped helper. - self.assertIn("gh_repair() {", wrapper) - self.assertNotIn("\n gh pr create", wrapper) - self.assertNotIn("\n gh issue create", wrapper) - self.assertNotIn("\n gh pr comment", wrapper) - self.assertNotIn("\n gh issue comment", wrapper) - self.assertNotIn("\n gh pr edit", wrapper) + def test_every_agent_edit_restarts_prepare_and_full_build(self) -> None: + main = self.wrapper[self.wrapper.index('phase="prepare"\nwhile true; do') :] + self.assertLess(main.index("run_prepare"), main.index("run_full_build")) + self.assertLess(main.index("run_full_build"), main.index("run_certification")) + self.assertLess(main.index("agent_turn"), main.rindex('phase="prepare"')) + prompt = self.wrapper[ + self.wrapper.index("repair_prompt() {") : self.wrapper.index("current_pr() {") + ] + self.assertIn("restart at prepare", prompt) + self.assertIn("complete build", prompt) + self.assertIn("full supported-family certification", prompt) - def test_certified_battery_is_bound_to_the_repair_pr_head(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # The wrapper — never the agent — commits and pushes the certified tree. - self.assertIn("publish_repair_branch", wrapper) - self.assertIn('CERTIFIED_SHA="$PUBLISHED_SHA"', wrapper) - # Success requires the remote PR head to equal the certified commit. - self.assertIn("verify_pr_head_is_certified", wrapper) - # The PR must exist before apply_pr_body runs: on a first run the PR - # is created lazily, and applying the agent's draft body before - # ensure_pr silently no-ops, leaving the generic body on the PR - # (live: run 33163990453 — the agent's full analysis never showed). - self.assertIn("ensure_pr >/dev/null\n apply_pr_body", wrapper) - self.assertIn("report_success", wrapper) - # The PR-body agent turn runs locally before certification; its body is - # applied only in a terminal reporting path. - self.assertIn("draft_pr_body", wrapper) - self.assertIn("apply_pr_body", wrapper) - main_flow = wrapper[wrapper.index('if [[ "$MODE" == "patch-queue" ]]', wrapper.index("repair_followup_prompt()")):] - self.assertLess(main_flow.index("draft_pr_body"), main_flow.index("certification attempt")) - self.assertNotIn("write_pr_body", wrapper) + def test_prepare_owns_pin_and_exact_prepared_upstream(self) -> None: + prepare = self.wrapper[ + self.wrapper.index("run_prepare() {") : self.wrapper.index("run_full_build() {") + ] + self.assertIn('scripts/update-llama-pin.sh "$UPSTREAM_SHA"', self.wrapper) + self.assertIn("verify_repair_pin", prepare) + self.assertIn("scripts/prepare-llama.sh pinned", prepare) + self.assertIn(".mesh-llm-upstream-sha", prepare) + self.assertNotIn("PIN_MIRROR_FILE", self.wrapper) - def test_repair_pr_is_published_only_from_terminal_paths(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - main_flow = wrapper[wrapper.index('if [[ "$MODE" == "patch-queue" ]]', wrapper.index("repair_followup_prompt()")):] + def test_build_gate_is_complete_and_precedes_certification(self) -> None: + build = self.wrapper[ + self.wrapper.index("run_full_build() {") : self.wrapper.index("run_certification() {") + ] + self.assertIn("LLAMA_STAGE_UPSTREAM_TESTS=ON", build) + self.assertIn("arch -arm64 bash scripts/build-llama.sh", build) + self.assertIn("candidate native archive must be arm64", build) + self.assertIn("scripts/check-skippy-generated-family-patch.sh", build) + for package in ( + "skippy-runtime", + "skippy-server", + "skippy-model-package", + "skippy-correctness", + ): + self.assertIn(f"-p {package}", build) + self.assertIn("scripts/skippy-ci-smoke.sh", build) - # Active repair/certification flow can draft local content but cannot - # push, create/update a PR, or comment directly. Every outcome funnels - # through terminal success/failure reporters. - self.assertNotIn("publish_work_in_progress", wrapper) - self.assertNotIn("publish_repair_branch", main_flow) - self.assertNotIn("ensure_pr", main_flow) - self.assertNotIn("apply_pr_body", main_flow) - self.assertNotIn("pr_comment", main_flow) - self.assertIn("report_success", main_flow) - self.assertIn("report_failure", main_flow) + def test_certification_is_full_and_uses_prebuilt_candidate(self) -> None: + certify = self.wrapper[ + self.wrapper.index("run_certification() {") : self.wrapper.index("phase_log() {") + ] + self.assertIn("skippy-llama-parity.py --llama-src .deps/llama.cpp validate", certify) + self.assertIn("--cadence llama-bump", certify) + self.assertNotIn("--families", certify) + self.assertIn("scripts/skippy-canary-live-matrix.sh --prepare", certify) + self.assertIn("scripts/skippy-family-battery.sh --skip-build --plan", certify) - report_success = wrapper[wrapper.index("report_success() {"):wrapper.index("report_failure() {")] - report_failure = wrapper[wrapper.index("report_failure() {"):wrapper.index("draft_pr_body() {")] - for reporter in (report_success, report_failure): - self.assertLess(reporter.index("publish_repair_branch"), reporter.index("ensure_pr")) - self.assertLess(reporter.index("ensure_pr"), reporter.index("apply_pr_body")) - self.assertLess(reporter.index("apply_pr_body"), reporter.index("\n pr_comment")) + def test_internal_deadline_reserves_terminal_publication_time(self) -> None: + self.assertIn('REPAIR_BUDGET_SECONDS="${CANARY_REPAIR_BUDGET_SECONDS:-41400}"', self.wrapper) + self.assertIn('PUBLISH_RESERVE_SECONDS="${CANARY_PUBLISH_RESERVE_SECONDS:-1800}"', self.wrapper) + self.assertIn("DEADLINE_AT - $(date +%s) - PUBLISH_RESERVE_SECONDS", self.wrapper) + self.assertIn("scripts/run-command-with-timeout.py", self.wrapper) + for label in ( + "apply llama.cpp patch queue", + "complete patched llama.cpp build", + "full supported-family certification", + "agent repair turn", + ): + self.assertIn(label, self.wrapper) + self.assertIn("publication reserve is active", self.wrapper) + self.assertIn("report_terminal failed", self.wrapper) - def test_repair_branch_selects_the_certified_upstream_through_checked_in_pin(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # The deterministic wrapper, not an agent turn, owns the repository - # selector. The sole pin must select the exact 40-hex target before a - # repair is published or certified. - self.assertIn("write_repair_pin() {", wrapper) - self.assertIn('scripts/update-llama-pin.sh "$UPSTREAM_SHA"', wrapper) - self.assertIn("verify_repair_pin() {", wrapper) - self.assertIn('pin="$(tr -d \'[:space:]\' < "$PIN_FILE")"', wrapper) - self.assertIn('[[ "$pin" != "$UPSTREAM_SHA" ]]', wrapper) - self.assertNotIn("PIN_MIRROR_FILE", wrapper) + def test_repair_turn_limit_is_per_phase(self) -> None: + for counter in ( + "PREPARE_REPAIR_TURNS", + "BUILD_REPAIR_TURNS", + "CERTIFY_REPAIR_TURNS", + ): + self.assertIn(counter, self.wrapper) + self.assertIn('phase_turns "$phase"', self.wrapper) + self.assertIn('increment_phase_turns "$phase"', self.wrapper) - # Certification must exercise the same path normal builds use and - # bind the prepared checkout stamp back to the requested target. - self.assertIn("prepare_repair_target() {", wrapper) - self.assertIn("write_repair_pin || return 1", wrapper) - self.assertIn("verify_repair_pin || return 1", wrapper) - self.assertIn("scripts/prepare-llama.sh pinned || return 1", wrapper) - self.assertIn('prepared_upstream="$(tr -d \'[:space:]\' < "$ROOT/.deps/llama.cpp/.mesh-llm-upstream-sha")"', wrapper) - self.assertIn('[[ "$prepared_upstream" != "$UPSTREAM_SHA" ]]', wrapper) - self.assertIn(" prepare_repair_target || return 1\n # Battery-mode repairs", wrapper) + def test_terminal_publication_uses_unique_branch_without_force_push(self) -> None: + self.assertIn('BRANCH="llama-canary/repair-${RUN_KEY}-${UPSTREAM_SHA:0:10}"', self.wrapper) + publish = self.wrapper[ + self.wrapper.index("publish_terminal_branch() {") : self.wrapper.index("write_pr_body() {") + ] + self.assertIn('"HEAD:refs/heads/${BRANCH}"', publish) + self.assertNotIn("+HEAD", publish) + self.assertNotIn("--force", publish) + main = self.wrapper[self.wrapper.index('phase="prepare"\nwhile true; do') :] + self.assertNotIn("publish_terminal_branch", main) + self.assertNotIn("gh pr create", main) + self.assertIn("report_terminal certified", main) + self.assertIn("report_terminal failed", main) - # Both entry modes establish the pinned target before terminal - # reporting can create or update the repair PR. - mode_start = wrapper.index( - 'if [[ "$MODE" == "patch-queue" ]]', - wrapper.index("repair_followup_prompt()"), - ) - mode_flow = wrapper[mode_start:] - patch_queue_branch, battery_and_shared = mode_flow.split("\nelse\n", 1) - battery_branch = battery_and_shared.split( - "\nfi\n\n# Draft locally before certification", 1 - )[0] - for branch in (patch_queue_branch, battery_branch): - self.assertLess( - branch.index("if ! prepare_repair_target; then"), - branch.index("\n report_failure"), - ) + def test_failed_terminal_state_is_draft_and_green_is_exact_head(self) -> None: + ensure = self.wrapper[ + self.wrapper.index("ensure_pr() {") : self.wrapper.index("verify_pr_head() {") + ] + self.assertIn("create_args=(--draft)", ensure) + self.assertIn("create_args=()", ensure) + self.assertIn("not certified and is not eligible to merge", self.wrapper) + report = self.wrapper[ + self.wrapper.index("report_terminal() {") : self.wrapper.index('phase="prepare"') + ] + self.assertLess(report.index("publish_terminal_branch"), report.index("ensure_pr")) + self.assertLess(report.index("ensure_pr"), report.index("verify_pr_head")) + self.assertIn('CERTIFIED_SHA="$PUBLISHED_SHA"', self.wrapper) - def test_post_green_review_changes_are_recertified_before_publication(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # Even a green, certified repair gets one fresh-context review turn: - # the reviewer is told it did NOT author the repair, and it may fix - # what parity certification cannot see (dropped patch intent, rebase - # leftovers, ABI mirror drift). Its changes ride as a separate - # review(llama): local commit, which must pass the complete battery - # again before the branch and PR are published. - self.assertIn("post_green_review_turn() {", wrapper) - certify_reviewed = wrapper[wrapper.index("certify_reviewed_repair() {"):wrapper.index("repair_followup_prompt() {")] - self.assertIn(" run_battery || return 1\n commit_repair_tree certified\n post_green_review_turn || return 1", certify_reviewed) - self.assertIn('if [[ "$REVIEW_CHANGED" == "true" ]]; then', certify_reviewed) - self.assertIn("running final certification", certify_reviewed) - self.assertGreater(certify_reviewed.count("run_battery"), 1) - self.assertIn("review(llama): agent review fixes at upstream", wrapper) - # The review is opt-out and fail-open: a disabled or crashed review - # never fails a green repair. - self.assertIn('if [[ "${CANARY_AGENT_REVIEW:-true}" != "true" ]]', wrapper) - self.assertIn("post-green agent review disabled", wrapper) - self.assertIn("inspecting any partial changes", wrapper) - # The success comment must report the review and recertification outcome honestly. - self.assertIn("REVIEW_STATUS=", wrapper) - self.assertIn("Post-green agent review made modifications", wrapper) - self.assertIn("final reviewed tree passed a second complete certification", wrapper) - # Review code itself must never publish; terminal success owns the only - # push and verifies the PR head equals the final certified bytes. - review = wrapper[wrapper.index("post_green_review_turn() {"):wrapper.index("certify_reviewed_repair() {")] - self.assertNotIn("git push", review) - self.assertNotIn("ensure_pr", review) - self.assertIn('"$remote_head" == "${CERTIFIED_SHA:?}"', wrapper) - self.assertNotIn("REVIEW_HEAD", wrapper) - # The review report is run-scoped persistent-runner state. - self.assertIn('rm -f "$ROOT/.deps/llama-canary-review-report.md"', wrapper) + def test_terminal_pr_creation_errors_remain_visible(self) -> None: + ensure = self.wrapper[ + self.wrapper.index("ensure_pr() {") : self.wrapper.index("verify_pr_head() {") + ] + self.assertIn("2> >(redact_token >&2)", ensure) + self.assertNotIn("--body-file \"$PR_BODY\" 2>/dev/null", ensure) + self.assertIn("could not create the terminal canary PR", ensure) + self.assertIn("creation returned no PR number", ensure) - def test_battery_mode_reuses_workflow_evidence_without_rerunning(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # Battery mode seeds from the workflow's teed evidence log when - # present and only runs a diagnostic battery when it is absent. - self.assertIn("reusing workflow battery evidence", wrapper) - self.assertIn("no workflow battery evidence", wrapper) - # A missing battery log must not crash the failure-path summaries. - self.assertIn("(no battery output captured", wrapper) - # The first battery-mode loop iteration is a repair turn seeded from - # the workflow evidence — never a second full build+battery run - # before the agent gets the failure output. + def test_runner_environment_is_validated_before_expensive_work(self) -> None: + preflight = self.wrapper[: self.wrapper.index("agent_turn() {")] self.assertIn( - 'if [[ "$MODE" == "battery" && "$attempt" -eq 1 ]]; then', wrapper - ) - self.assertIn( - "battery mode: repair turn 1 seeded from the workflow battery failure evidence", - wrapper, + "for required_name in LLAMA_STAGE_BUILD_DIR HF_CACHE GITHUB_REPOSITORY", + preflight, ) + self.assertIn("CANARY_REPAIR_TOKEN is not set", preflight) + self.assertNotIn("${LLAMA_STAGE_BUILD_DIR:?}", self.wrapper) + self.assertNotIn("${HF_CACHE:?}", self.wrapper) - def test_agent_turns_emit_heartbeat_progress(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # Long agent turns must stay observable from the Actions log: a - # heartbeat monitor prints elapsed time and worktree activity every - # 10 minutes, runs without ambient credentials, and is killed as a - # whole process group when the turn ends. set -m (not setsid) makes - # the group portable to the macOS family-certify runner, and plain - # find -print (not -printf) is used for the same reason. - self.assertIn("heartbeat: agent turn running for", wrapper) - self.assertIn("while sleep 600", wrapper) - self.assertIn('env -i PATH="$PATH" bash -c', wrapper) - self.assertIn('heartbeat "$ROOT" "$started"', wrapper) - self.assertIn("set -m", wrapper) - self.assertNotIn("setsid", wrapper) - self.assertNotIn("-printf", wrapper) - self.assertIn('kill -- "-$heartbeat_pid"', wrapper) - self.assertIn('wait "$heartbeat_pid"', wrapper) - self.assertIn("recent worktree activity", wrapper) + target = "a" * 40 + base_env = { + **os.environ, + "CANARY_REPAIR_TOKEN": "fixture-token", + "GITHUB_REPOSITORY": "Mesh-LLM/mesh-llm", + "HF_CACHE": "/tmp/fixture-hf-cache", + "LLAMA_STAGE_BUILD_DIR": "/tmp/fixture-llama-build", + } + for missing in ("LLAMA_STAGE_BUILD_DIR", "HF_CACHE", "GITHUB_REPOSITORY"): + with self.subTest(missing=missing): + env = {**base_env} + env.pop(missing) + result = subprocess.run( + [str(WRAPPER), target], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + timeout=30, + ) + self.assertEqual(1, result.returncode) + self.assertIn(f"{missing} is not set", result.stderr) - def test_every_github_call_is_token_scoped(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # The workflow job exports no ambient GH_TOKEN and checks out with - # persist-credentials disabled: every gh invocation — reads included - # — must go through the token-scoped helper. - lines = [ - line - for line in wrapper.splitlines() - if not line.lstrip().startswith("#") - and " gh " in f" {line.strip()} " - # `command -v gh` checks binary presence, not an API call. - and "command -v" not in line - ] - for line in lines: - self.assertIn( - "gh_repair", - line, - f"bare gh invocation bypasses the repair token: {line.strip()}", + def test_git_push_uses_env_sourced_askpass_without_token_in_argv(self) -> None: + publish = self.wrapper[ + self.wrapper.index("publish_terminal_branch() {") : self.wrapper.index( + "write_upstream_summary() {" ) + ] + self.assertIn('GIT_ASKPASS="$GIT_ASKPASS_SCRIPT"', publish) + self.assertIn("GIT_TERMINAL_PROMPT=0", publish) + self.assertIn('git push "https://github.com/${GITHUB_REPOSITORY}.git"', publish) + self.assertNotIn("x-access-token:${CANARY_REPAIR_TOKEN}", self.wrapper) + self.assertIn('os.environ["CANARY_REPAIR_TOKEN"]', self.wrapper) + self.assertIn(".replace(token,", self.wrapper) - def test_run_scopes_persistent_runner_state(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # The PR-body draft is cleared every run; the battery evidence log is - # cleared in patch-queue mode but preserved in battery mode, where it - # holds this run's workflow-teed evidence. - self.assertIn( - 'rm -f "$ROOT/.deps/llama-canary-pr-body.md"', wrapper + redact = self.wrapper[ + self.wrapper.index("redact_token() {") : self.wrapper.index( + "remaining_work_seconds() {" + ) + ] + token = "fixture/[]$.*\\token" + result = subprocess.run( + ["bash", "-c", f"{redact}\nprintf '%s' \"$INPUT\" | redact_token"], + env={**os.environ, "CANARY_REPAIR_TOKEN": token, "INPUT": f"a{token}b"}, + text=True, + capture_output=True, + check=False, ) - self.assertIn('if [[ "$MODE" == "patch-queue" ]]; then\n rm -f "$BATTERY_LOG"', wrapper) - # Scratch worktrees under /tmp and their registrations survive across - # runs on the persistent runner and make the agent's own - # `git worktree add` fail; the wrapper prunes them up front (live: - # run 33158798988 aborted its turn on a stale /tmp/llama-old-pin). - self.assertIn('git -C "$ROOT/.deps/llama.cpp" worktree prune', wrapper) - self.assertIn("rm -rf /tmp/llama-old-pin /tmp/llama-repair /tmp/llama-repair-*", wrapper) - # The repair push URL embeds the token; its stderr is redacted. - self.assertIn("redact_token", wrapper) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("a***redacted***b", result.stdout) - def test_token_permissions_are_preflighted_before_repair_work(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # A permission gap on CANARY_REPAIR_TOKEN must fail the run in - # seconds, before any repair work — not as a git 403 after a - # potentially hours-long certified repair (live: run 33153507371, - # where the account HAD push but the fine-grained PAT lacked - # Contents: write, so REST permissions passed while git push 403'd). - self.assertIn("check_repair_token_permissions", wrapper) - # The preflight authenticates the token and PROBES the actual write - # capability (create+delete a temp ref) via scoped gh calls — reading - # the REST permissions object is not sufficient, because it reflects - # the account's access, not the token's fine-grained scope. - self.assertIn('gh_repair gh api user --jq .login', wrapper) - self.assertIn('gh_repair gh api --method POST "repos/${GITHUB_REPOSITORY:?}/git/refs"', wrapper) - self.assertIn('gh_repair gh api --method DELETE', wrapper) - # The probe ref must actually be cleaned up: the delete URL uses the - # percent-encoded branch name under /git/refs/ (a refs/-prefixed path - # 404s and leaves the probe branch behind; live: run 33158798988). - self.assertIn('git/refs/heads%2Fcanary-repair-token-preflight', wrapper) - # It runs unconditionally before the repair loop starts. - preflight_call = wrapper.index("check_repair_token_permissions\n\n# Run-scope") - self.assertGreater(preflight_call, wrapper.index("gh_repair()")) - self.assertLess(preflight_call, wrapper.index("agent_turn")) + def test_pr_body_contains_generated_upstream_summary(self) -> None: + summary = self.wrapper[ + self.wrapper.index("write_upstream_summary() {") : self.wrapper.index( + "ensure_pr() {" + ) + ] + self.assertIn("scripts/summarize-llama-upstream.sh", summary) + self.assertIn("$OLD_SHA", summary) + self.assertIn("$UPSTREAM_SHA", summary) + self.assertIn('cat "$UPSTREAM_SUMMARY"', summary) + self.assertIn("automated upstream summary was unavailable", summary) - def test_battery_build_mirrors_the_workflow_arch_guard(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # The family-certify job runs under Rosetta; the wrapper's build must - # use the same arch -arm64 guard as the workflow's own build step, - # and refuse to certify a non-arm64 archive (run 33140672269 rebuilt - # x86_64 from a plain build-llama.sh call). - self.assertIn( - "LLAMA_STAGE_UPSTREAM_TESTS=ON uv run --no-project --with jinja2==3.1.6 --", - wrapper, - ) - self.assertIn("arch -arm64 scripts/build-llama.sh", wrapper) - self.assertIn("-DCMAKE_OSX_ARCHITECTURES=arm64 || return 1", wrapper) - self.assertIn("refusing to certify: native archive is not arm64", wrapper) + def test_phase_error_propagation_is_documented_as_load_bearing(self) -> None: + main = self.wrapper[self.wrapper.index('phase="prepare"\nwhile true; do') :] + self.assertIn("inherits disabled errexit", main) + self.assertIn("explicit `|| return 1`", main) + self.assertIn("This is load-bearing", main) - def test_push_failure_names_the_likely_permission_cause(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - # A 403 on the repair push is almost always a PAT-identity permission - # gap; the wrapper must say so instead of failing with a bare git error. - self.assertIn("identity behind CANARY_REPAIR_TOKEN lacks write access", wrapper) + def test_agent_runbook_matches_wrapper_owned_state_machine(self) -> None: + runbook = RUNBOOK.read_text(encoding="utf-8") + self.assertIn("prepare -> build -> certify -> publish", runbook) + self.assertIn("scripts/prepare-llama.sh pinned", runbook) + self.assertIn("Do not switch or create a branch in the mesh-llm", runbook) + self.assertIn("Do not push, open a PR, or use GitHub credentials", self.wrapper) + for obsolete in ( + "patch-queue mode", + "battery mode", + "llama-canary/patch-queue-fix", + "separate review agent", + ): + self.assertNotIn(obsolete, runbook) - def test_dispatch_sha_is_validated_before_use(self) -> None: - env = { - **os.environ, - "UPSTREAM_SHA_INPUT": "not-a-sha; echo pwned", - "CANARY_REPAIR_TOKEN": "test-token", - "GITHUB_REPOSITORY": "Mesh-LLM/mesh-llm", - } - env["PATH"] = str(ROOT / "scripts" / "tests" / "fixtures") + os.pathsep + env.get("PATH", "") - with tempfile.TemporaryDirectory() as tmp: - # The prerequisite checks (opencode, credentials) intentionally - # pass in this environment only when the fixtures exist; run the - # script and require it to never accept the invalid SHA. - result = subprocess.run( - [str(REPAIR), "patch-queue"], - cwd=tmp, - env=env, - text=True, - capture_output=True, - check=False, - timeout=60, - ) - combined = result.stdout + result.stderr - # The crafted SHA is refused as non-40-hex and never executed: the - # only place it appears is the refusal message itself. - self.assertIn("refusing to repair against a non-40-hex upstream SHA", combined) - self.assertEqual(1, result.returncode) - self.assertEqual( - 1, - combined.count("pwned"), - "the crafted SHA must only appear in the refusal message, never as executed output", - ) + def test_agent_has_no_github_credentials_or_publication_authority(self) -> None: + self.assertNotIn("export GH_TOKEN", self.wrapper) + agent = self.wrapper[ + self.wrapper.index("agent_turn() {") : self.wrapper.index("write_repair_pin() {") + ] + self.assertIn("-u GH_TOKEN -u GITHUB_TOKEN -u CANARY_REPAIR_TOKEN", agent) + self.assertIn('opencode run --auto --model "$AGENT_MODEL"', agent) + self.assertNotIn("git push", agent) + self.assertNotIn("gh pr", agent) + self.assertIn("heartbeat: agent repair running for", agent) - def test_manual_positional_sha_is_still_validated(self) -> None: - script = subprocess.run( - ["bash", "-n", str(REPAIR)], - capture_output=True, + def test_every_github_call_is_token_scoped(self) -> None: + for line in self.wrapper.splitlines(): + stripped = line.strip() + if stripped.startswith("#") or " gh " not in f" {stripped} ": + continue + self.assertIn("gh_repair", stripped, stripped) + + def test_token_permission_probe_is_unique_and_runs_before_work(self) -> None: + self.assertIn("canary-repair-token-preflight-${RUN_KEY}", self.wrapper) + self.assertIn("git/refs/heads%2F${probe_branch}", self.wrapper) + call = self.wrapper.index("check_repair_token_permissions\n") + self.assertLess(call, self.wrapper.index("agent_turn()")) + self.assertLess(call, self.wrapper.index("run_prepare()")) + + def test_dispatch_sha_is_rejected_before_use(self) -> None: + crafted = "not-a-sha; echo pwned" + result = subprocess.run( + [str(WRAPPER)], + cwd=ROOT, + env={**os.environ, "UPSTREAM_SHA_INPUT": crafted}, text=True, + capture_output=True, check=False, + timeout=30, ) - self.assertEqual(0, script.returncode, script.stderr) + combined = result.stdout + result.stderr + self.assertEqual(1, result.returncode) + self.assertIn("non-40-hex upstream SHA", combined) + self.assertEqual(1, combined.count("pwned")) - def test_battery_mode_certification_executes_the_full_live_matrix(self) -> None: - """A battery-mode repair cannot certify without executing every - pinned live package-v2 row and the generated-family-patch gate.""" - wrapper = REPAIR.read_text(encoding="utf-8") - run_battery = wrapper[wrapper.index("run_battery() {"):wrapper.index("post_green_review_turn()")] - # Source transformation replaces the one-family expansion target. - # Battery-mode certification validates the manifests and runs the - # complete pinned live matrix before the family battery can pass. - self.assertIn('if [[ "$MODE" == "battery" ]]; then', run_battery) - self.assertIn("skippy-llama-parity.py --llama-src .deps/llama.cpp", run_battery) - self.assertIn('skippy-canary-live-matrix.sh --prepare', run_battery) - self.assertNotIn('--model', run_battery) - self.assertIn("repair cannot certify", run_battery) - self.assertIn("check-skippy-generated-family-patch.sh", run_battery) - # Validate runs before the live matrix; the generated-patch check and - # family battery run only after the native build succeeds. - self.assertLess( - run_battery.index("skippy-llama-parity.py --llama-src"), - run_battery.index("skippy-canary-live-matrix.sh"), - ) - self.assertLess( - run_battery.index("skippy-canary-live-matrix.sh"), - run_battery.index("skippy-family-battery.sh"), - ) - self.assertLess( - run_battery.index("check-skippy-generated-family-patch.sh"), - run_battery.index("skippy-family-battery.sh"), + def test_shell_syntax(self) -> None: + result = subprocess.run( + ["bash", "-n", str(WRAPPER)], capture_output=True, text=True, check=False ) + self.assertEqual(0, result.returncode, result.stderr) - def test_one_family_coverage_target_is_retired(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - run_battery = wrapper[wrapper.index("run_battery() {"):wrapper.index("post_green_review_turn()")] - self.assertNotIn("next-boundary-target --json", run_battery) - self.assertNotIn("llama-canary-coverage-target.json", wrapper) - self.assertNotIn("coverage_model", run_battery) - self.assertIn("single generated family patch", run_battery) - - def test_generated_patch_failure_blocks_certification(self) -> None: - wrapper = REPAIR.read_text(encoding="utf-8") - run_battery = wrapper[wrapper.index("run_battery() {"):wrapper.index("post_green_review_turn()")] - self.assertIn("if ! scripts/check-skippy-generated-family-patch.sh", run_battery) - self.assertIn("generated model-family patch is stale or invalid", run_battery) - patch_failure = run_battery.split( - "if ! scripts/check-skippy-generated-family-patch.sh", 1 - )[1].split("fi", 1)[0] - self.assertIn("return 1", patch_failure) + def test_persistent_runner_scratch_is_scoped_and_pruned(self) -> None: + self.assertIn('STATE_DIR="$ROOT/.deps/llama-canary-state-${RUN_KEY}"', self.wrapper) + self.assertIn('TARGET_SHA_FILE="$ROOT/.deps/llama-canary-target-sha"', self.wrapper) + self.assertIn('printf \'%s\\n\' "$UPSTREAM_SHA" > "$TARGET_SHA_FILE"', self.wrapper) + self.assertIn('git -C "$ROOT/.deps/llama.cpp" worktree prune', self.wrapper) + self.assertIn("rm -rf /tmp/llama-old-pin /tmp/llama-repair /tmp/llama-repair-*", self.wrapper) + self.assertIn("redact_token", self.wrapper) def test_runnable_row_carrying_unsupported_reason_is_rejected(self) -> None: - """validate must reject candidate/candidate_stateful rows carrying - an unsupported_reason — only non-runnable rows may carry one.""" parity = ROOT / "scripts" / "skippy-llama-parity.py" sys.path.insert(0, str(parity.parent)) try: - spec = importlib.util.spec_from_file_location( - "skippy_llama_parity_validate", parity - ) + spec = importlib.util.spec_from_file_location("skippy_llama_parity_validate", parity) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) finally: sys.path.pop(0) for status in ("certified", "candidate", "candidate_stateful"): - rows = [ - { - "llama_model": "somearch", - "status": status, - "unsupported_reason": "ambiguous leftover", - } - ] - self.assertEqual( - module.validate_boundary_registration(rows, {"somearch"}), - 1, - f"{status} row with a reason must fail even when registered", - ) - # A non-runnable classification with a reason stays legal. + rows = [{"llama_model": "somearch", "status": status, "unsupported_reason": "leftover"}] + self.assertEqual(module.validate_boundary_registration(rows, {"somearch"}), 1) self.assertEqual( module.validate_boundary_registration( - [ - { - "llama_model": "x", - "status": "non_causal_aux", - "unsupported_reason": "non-causal encoder", - } - ], + [{"llama_model": "x", "status": "non_causal_aux", "unsupported_reason": "non-causal encoder"}], set(), ), 0, diff --git a/scripts/tests/test_llama_upstream_canary_contract.py b/scripts/tests/test_llama_upstream_canary_contract.py index a5bf793797..2fb75e25f0 100644 --- a/scripts/tests/test_llama_upstream_canary_contract.py +++ b/scripts/tests/test_llama_upstream_canary_contract.py @@ -89,7 +89,7 @@ def test_workflow_and_wrapper_use_valid_invocations(self) -> None: ) self.assertNotIn("next-boundary-target", workflow) self.assertIn( - "skippy-llama-parity.py --llama-src .deps/llama.cpp \\", wrapper + "skippy-llama-parity.py --llama-src .deps/llama.cpp validate", wrapper ) for text in (workflow, wrapper): self.assertNotIn("validate --llama-src", text) @@ -129,23 +129,13 @@ def test_workflow_builds_binaries_before_skipping_per_lane_builds(self) -> None: native_build, ) - family_selection = _step_block(workflow, "Select changed generated families") - self.assertIn("select-skippy-family-shards.py", family_selection) - self.assertIn("--include-sentinels", family_selection) - self.assertIn("--changed-paths /tmp/changed-llama-sources.txt", family_selection) - self.assertIn("--family-map ci/llama-canary/generated-family-map.json", family_selection) - self.assertIn('git -C .deps/llama.cpp diff --name-only', family_selection) - self.assertIn('steps.sha.outputs.old_sha', family_selection) - self.assertIn('steps.sha.outputs.new_sha', family_selection) - self.assertNotIn("HEAD^", family_selection) - family_plan = _step_block(workflow, "Plan and verify family certification cache") self.assertIn("python3 scripts/plan-family-battery.py", family_plan) self.assertIn('--cadence "${{ steps.sha.outputs.cadence }}"', family_plan) self.assertIn("--check-cache", family_plan) self.assertIn('--cache-root "$HF_CACHE"', family_plan) self.assertIn('--github-output "$GITHUB_OUTPUT"', family_plan) - self.assertIn('family_args=(--families "${{ steps.family_selection.outputs.families }}")', family_plan) + self.assertNotIn("--families", family_plan) self.assertLess(workflow.index(family_plan), workflow.index(native_build)) build = _step_block(workflow, "Build stage runtime crates") @@ -177,13 +167,14 @@ def test_workflow_builds_binaries_before_skipping_per_lane_builds(self) -> None: self.assertIn("target/family-battery/", upload) self.assertIn("retention-days: 14", upload) - capture = _step_block(workflow, "Capture upstream SHAs") - self.assertIn('"$FORCE_CERTIFY" == "true"', capture) - self.assertIn('echo "certify=true"', capture) - self.assertIn('echo "cadence=manual-full"', capture) - self.assertIn('echo "cadence=llama-bump"', capture) - self.assertIn('"$GITHUB_EVENT_NAME" == "schedule"', capture) - self.assertIn('echo "cadence=nightly"', capture) + resolve = _step_block(workflow, "Resolve requested llama.cpp upstream") + self.assertIn('new_sha="$(git ls-remote', resolve) + self.assertIn('"$FORCE_CERTIFY" == "true"', resolve) + self.assertIn('echo "certify=true"', resolve) + self.assertIn('echo "cadence=manual-full"', resolve) + self.assertIn('echo "cadence=llama-bump"', resolve) + self.assertIn('"$GITHUB_EVENT_NAME" == "schedule"', resolve) + self.assertIn('echo "cadence=nightly"', resolve) forced_report = _step_block(workflow, "Report forced certification result") self.assertIn("steps.sha.outputs.cadence == 'manual-full'", forced_report) @@ -194,27 +185,23 @@ def test_workflow_builds_binaries_before_skipping_per_lane_builds(self) -> None: def test_persistent_runner_executes_only_trusted_main_with_read_access(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - latest_job = workflow[workflow.index(" latest-upstream:") : workflow.index(" update-pin:")] - update_job = workflow[workflow.index(" update-pin:") :] - self.assertIn("runs-on: [self-hosted, family-certify]", latest_job) - self.assertIn("permissions:\n contents: read", latest_job) - self.assertIn("ref: main", latest_job) - self.assertIn("fetch-depth: 1", latest_job) + self.assertIn("runs-on: [self-hosted, family-certify]", workflow) + self.assertIn("permissions:\n contents: read", workflow) + self.assertIn("ref: main", workflow) + self.assertIn("fetch-depth: 1", workflow) self.assertNotIn("queue_ref", workflow) - self.assertNotIn("github.token", latest_job) - self.assertIn("runs-on: ubuntu-latest", update_job) - self.assertIn("permissions:\n contents: write", update_job) - self.assertIn("trusted_queue_sha", update_job) + self.assertNotIn("github.token", workflow) + self.assertNotIn("contents: write", workflow) - def test_update_job_writes_the_single_upstream_pin(self) -> None: + def test_changed_pin_never_pushes_directly_to_main(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - update_step = _step_block(workflow, "Commit validated upstream pin to main") - self.assertIn('scripts/update-llama-pin.sh "$VALIDATED_SHA"', update_step) - self.assertIn( - "git add third_party/llama.cpp/upstream.txt", - update_step, - ) - self.assertNotIn("LLAMA_CPP_SHA", update_step) + changed = _step_block(workflow, "Changed-pin prepare, build, certify, and publish") + self.assertIn("steps.sha.outputs.changed == 'true'", changed) + self.assertIn("scripts/llama-canary-agent-repair.sh", changed) + self.assertIn("CANARY_REPAIR_TOKEN:", changed) + self.assertIn("UPSTREAM_SHA_INPUT:", changed) + self.assertNotIn("update-pin:", workflow) + self.assertNotIn("HEAD:refs/heads/main", workflow) def test_update_pin_script_writes_pin_and_rejects_invalid_sha(self) -> None: updater = UPDATE_PIN.read_text(encoding="utf-8") @@ -269,186 +256,54 @@ def test_update_pin_script_writes_pin_and_rejects_invalid_sha(self) -> None: self.assertEqual(0, prepared.returncode, prepared.stderr) self.assertEqual(prepared_target + "\n", pin.read_text(encoding="utf-8")) - def test_repair_loop_is_wired_for_both_failure_modes(self) -> None: + def test_changed_pin_uses_one_terminal_state_machine(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - queue_repair = _step_block(workflow, "Agent repair loop (patch-queue failure)") - self.assertIn("steps.prepare.outcome == 'failure'", queue_repair) - self.assertIn("scripts/llama-canary-agent-repair.sh patch-queue", queue_repair) - # The repair loop must not silently turn the canary green. - self.assertIn("continue-on-error: true", queue_repair) - - battery_repair = _step_block(workflow, "Agent repair loop (battery failure)") - self.assertIn("steps.prepare.outcome == 'success'", battery_repair) - self.assertIn("steps.battery.outcome == 'failure'", battery_repair) - self.assertIn("steps.sha.outputs.cadence != 'nightly'", battery_repair) - self.assertIn("scripts/llama-canary-agent-repair.sh battery", battery_repair) - self.assertIn("continue-on-error: true", battery_repair) - - # Any repair outcome keeps the run red: the certified fix must merge - # through the repair PR before trusted main can certify. - fail_step = _step_block(workflow, "Fail when the canary needs human attention") - self.assertIn("steps.repair_queue.outcome", fail_step) - self.assertIn("steps.repair_battery.outcome", fail_step) - self.assertIn("CANARY_CADENCE:", fail_step) - self.assertIn('[[ "$CANARY_CADENCE" == "nightly" ]]', fail_step) - self.assertIn("repair agent was intentionally not invoked", fail_step) - self.assertIn("exit 1", fail_step) - - # The battery lane itself no longer hard-fails the job before the - # repair loop can run. - battery = _step_block(workflow, "Supported-families certification battery (parity gate)") - self.assertIn("continue-on-error: true", battery) - - # Both repair paths use the dedicated token, never the job token. - self.assertNotIn("github.token", workflow[workflow.index(" latest-upstream:") : workflow.index(" update-pin:")]) - - # Untrusted dispatch SHAs reach Bash only as environment variables. - for repair_step in (queue_repair, battery_repair): - self.assertIn("UPSTREAM_SHA_INPUT:", repair_step) - self.assertIn("CANARY_REPAIR_TOKEN:", repair_step) - # Extract the run: command body (everything after "run: |" or "run:") - # to ensure inline interpolation checks only inspect shell commands, - # not the env: mapping where ${{ }} is safe and intended. - run_marker = repair_step.find("\n run:") - self.assertNotEqual(-1, run_marker, "repair step must have a run: key") - run_body = repair_step[run_marker + len("\n run:"):] - self.assertNotIn("github.event.inputs", run_body) - self.assertNotIn("steps.sha.outputs", run_body) - - # The workflow's battery step appends its evidence log (the parity - # validation gate writes the head of the same file) so a - # battery-mode repair turn reuses it instead of re-running. - self.assertIn("tee -a .deps/llama-canary-repair-battery.log", battery) - - # The parity manifest validation gate runs before the family plan, - # fail-closed, and its failure feeds the same battery repair loop. - parity_gate = _step_block( - workflow, "Parity manifest validation (boundary registration gate)" - ) - self.assertIn("skippy-llama-parity.py --llama-src .deps/llama.cpp validate", parity_gate) - self.assertIn("continue-on-error: true", parity_gate) - gate_idx = workflow.index("Parity manifest validation (boundary registration gate)") - plan_idx = workflow.index("Plan and verify family certification cache") - self.assertLess(gate_idx, plan_idx, "parity gate must run before the family plan") - self.assertIn("steps.parity_validate.outcome == 'failure'", battery_repair) - self.assertIn("steps.parity_validate.outcome == 'failure'", fail_step) - - # The live package-v2 two-node matrix makes model_pin rows executable - # evidence and routes failures to the same repair loop. - live_matrix = _step_block( - workflow, "Live package-v2 two-node matrix (model_pin proof)" - ) - self.assertIn("scripts/skippy-canary-live-matrix.sh --prepare", live_matrix) - self.assertIn("set -o pipefail", live_matrix) - self.assertIn("continue-on-error: true", live_matrix) - self.assertIn("steps.live_matrix.outcome == 'failure'", battery_repair) - self.assertIn("steps.live_matrix.outcome == 'failure'", fail_step) - # The live step must build this run's exact producers (host binary + - # patched native runtime) with an explicit backend — no cached - # binary/bundle may supply the matrix. - self.assertIn("SKIPPY_CANARY_LIVE_MATRIX_BACKEND", live_matrix) - self.assertIn("metal", live_matrix) - - # The source rewriter replaces the one-family boundary-expansion loop. - # Its deterministic generated-patch check must route drift to the - # repair loop and keep every success report fail-closed. - family_patch = _step_block(workflow, "Verify generated model-family patch") - self.assertIn("id: family_patch", family_patch) - self.assertIn("continue-on-error: true", family_patch) - self.assertIn("scripts/check-skippy-generated-family-patch.sh", family_patch) - self.assertNotIn("next-boundary-target", workflow) - self.assertNotIn("llama-canary-coverage-target.json", workflow) - self.assertIn("steps.family_patch.outcome == 'failure'", battery_repair) - self.assertIn("steps.family_patch.outcome == 'failure'", fail_step) - self.assertIn("family_patch_outcome: ${{ steps.family_patch.outcome }}", workflow) - update_job = workflow[workflow.index(" update-pin:") :] - self.assertIn("needs.latest-upstream.outputs.family_patch_outcome == 'success'", update_job) - - generated_patch_check = ROOT / "scripts" / "check-skippy-generated-family-patch.sh" - generated_patch_check_text = generated_patch_check.read_text(encoding="utf-8") - self.assertIn("llvm@22", generated_patch_check_text) - self.assertIn("patches/generated", generated_patch_check_text) - self.assertIn("generated-family-map.json", generated_patch_check_text) - self.assertIn("select-skippy-family-shards.py", generated_patch_check_text) - self.assertIn("generate-skippy-family-patch.py", generated_patch_check_text) - self.assertIn("skippy-rewriter-harness.py", generated_patch_check_text) - self.assertIn("skippy-noalloc-graph-planning", generated_patch_check_text) - self.assertIn("ORIGINAL_SOURCE_HEAD", generated_patch_check_text) - self.assertIn("GENERATED_PATCH_COUNT", generated_patch_check_text) - self.assertIn('--diff-base "$CORE_SOURCE_HEAD"', generated_patch_check_text) - self.assertIn("core-only generator input already contains", generated_patch_check_text) - self.assertIn("stage_filter", generated_patch_check_text) - self.assertIn("begin_block", generated_patch_check_text) - self.assertIn("end_block", generated_patch_check_text) - stage_free_model_patch = ( - ROOT - / "third_party" - / "llama.cpp" - / "patches" - / "0019-skippy-add-stage-free-model-semantics.patch" - ).read_text(encoding="utf-8") - self.assertNotRegex(stage_free_model_patch, r"\bstage_filter\b") - self.assertNotRegex(stage_free_model_patch, r"\bbegin_block\s*\(") - self.assertNotRegex(stage_free_model_patch, r"\bend_block\s*\(") - self.assertIn("-R '^skippy_'", generated_patch_check_text) - generator_index = generated_patch_check_text.index( - 'python3 "$ROOT/scripts/generate-skippy-family-patch.py"' - ) - compile_index = generated_patch_check_text.index( - 'cmake --build "$LLAMA_BUILD_DIR"' + duplicate = _step_block(workflow, "Detect existing changed-pin canary PR") + self.assertIn("github.event_name == 'schedule'", duplicate) + self.assertIn("$CANDIDATE_SHA in:body", duplicate) + self.assertIn('startswith("llama-canary/repair-")', duplicate) + self.assertIn('contains("- Candidate pin: `\\($sha)`")', duplicate) + self.assertIn('echo "found=true"', duplicate) + + changed = _step_block( + workflow, "Changed-pin prepare, build, certify, and publish" ) - self.assertIn( - '--target "${TRANSFORMED_TREE_TARGETS[@]}"', - generated_patch_check_text, - ) - self.assertIn("skippy-stage-slice-plan", generated_patch_check_text) - verify_index = generated_patch_check_text.index( - 'ctest --test-dir "$LLAMA_BUILD_DIR"' - ) - self.assertLess(generator_index, compile_index) - self.assertLess(compile_index, verify_index) - self.assertIn('--compile-result "$compile_result"', generated_patch_check_text) - self.assertIn( - '--graph-verify-result "$graph_verify_result"', - generated_patch_check_text, - ) - self.assertNotIn("--compile-result pass", generated_patch_check_text) - self.assertNotIn("--graph-verify-result pass", generated_patch_check_text) - - native_build = _step_block(workflow, "Build patched llama.cpp ABI") - self.assertIn('LLAMA_STAGE_UPSTREAM_TESTS: "ON"', native_build) - self.assertIn("uv run --no-project --with jinja2==3.1.6", native_build) - - # Truthful success reporting requires generated patch + parity + live - # + battery gates to be green on every cadence. - pin_report = _step_block(workflow, "Report upstream pin update") - forced_report = _step_block(workflow, "Report forced certification result") - nightly_report = _step_block(workflow, "Report nightly family result") - for report in (pin_report, forced_report, nightly_report): - self.assertIn("steps.battery.outcome == 'success'", report) - self.assertIn("steps.parity_validate.outcome == 'success'", report) - self.assertIn("steps.live_matrix.outcome == 'success'", report) - self.assertIn("steps.family_patch.outcome == 'success'", report) - # Live-matrix and generated-patch evidence are uploaded together. - upload = _step_block(workflow, "Upload supported-families battery evidence") - self.assertIn("target/family-battery/", upload) - self.assertIn("target/skippy-stage-rewriter-check/", upload) + self.assertIn("steps.sha.outputs.changed == 'true'", changed) + self.assertIn("steps.existing_changed_pin.outputs.found != 'true'", changed) + self.assertIn("timeout-minutes: 720", changed) + self.assertIn("continue-on-error: true", changed) + self.assertIn("CANARY_REPAIR_BUDGET_SECONDS: \"41400\"", changed) + self.assertIn("CANARY_PUBLISH_RESERVE_SECONDS: \"1800\"", changed) + self.assertIn("CANARY_REPAIR_TOKEN:", changed) + self.assertIn("UPSTREAM_SHA_INPUT:", changed) + self.assertIn("scripts/llama-canary-agent-repair.sh", changed) + self.assertNotIn("patch-queue", changed) + self.assertNotIn(" battery", changed) + + stop = _step_block(workflow, "Stop after changed-pin terminal PR") + self.assertIn("steps.sha.outputs.changed == 'true'", stop) + self.assertIn("!cancelled()", stop) + self.assertNotIn("always()", stop) + self.assertIn("exit 1", stop) + self.assertIn("EXISTING_CANARY_FOUND", stop) + self.assertIn("Skipped duplicate scheduled certification", stop) + self.assertIn("duplicate-PR lookup failed", stop) + self.assertIn("A preflight failure can stop before any branch or PR", stop) + + upload = _step_block(workflow, "Upload changed-pin canary evidence") + self.assertIn("llama-canary-state-${{ github.run_id }}-${{ github.run_attempt }}", upload) + self.assertIn("llama-canary-changed-pin-${{ github.run_id }}-${{ github.run_attempt }}", upload) + self.assertNotIn("name: llama-family-battery-", upload) + self.assertIn("retention-days: 14", upload) - def test_post_green_agent_review_is_wired_and_opt_out(self) -> None: + def test_post_green_modifying_review_is_removed(self) -> None: workflow = WORKFLOW.read_text(encoding="utf-8") - # After a certified repair, the wrapper runs one fresh-context review - # turn that may modify the repair (a separate review(llama): commit). - # Both repair steps pass the opt-out var with the same vars-pattern - # as CANARY_AGENT_MODEL, defaulting to enabled. - for repair_step in ( - _step_block(workflow, "Agent repair loop (patch-queue failure)"), - _step_block(workflow, "Agent repair loop (battery failure)"), - ): - self.assertIn("CANARY_AGENT_REVIEW:", repair_step) - self.assertIn( - "CANARY_AGENT_REVIEW: ${{ vars.LLAMA_CANARY_AGENT_REVIEW || 'true' }}", - repair_step, - ) + wrapper = (ROOT / "scripts" / "llama-canary-agent-repair.sh").read_text( + encoding="utf-8" + ) + self.assertNotIn("CANARY_AGENT_REVIEW", workflow) + self.assertNotIn("post_green", wrapper) + self.assertNotIn("post-green", wrapper) def test_family_results_have_typed_failure_outcomes(self) -> None: certify = FAMILY_CERTIFY.read_text(encoding="utf-8") From 4fce94c110a1fb74e7650d40fee7073b6b0a3ba7 Mon Sep 17 00:00:00 2001 From: Virgile Pourchet Date: Fri, 4 Sep 2026 11:19:15 +0200 Subject: [PATCH 23/41] test(model-hf): make the read-only filesystem warning test portable read_only_filesystem_warning_includes_os_error_and_recovery_path builds io::Error::from_raw_os_error(30) and asserted the POSIX wording "Read-only file system". Raw OS error 30 is EROFS on POSIX but ERROR_READ_FAULT on Windows, so the test failed on native Windows while the code under test was fine. Assert the actual contract instead: the warning surfaces the OS error text verbatim, whatever the platform's wording for code 30 is. --- crates/model-hf/src/cache_paths.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/model-hf/src/cache_paths.rs b/crates/model-hf/src/cache_paths.rs index f20b8dafe7..7e0aa0d021 100644 --- a/crates/model-hf/src/cache_paths.rs +++ b/crates/model-hf/src/cache_paths.rs @@ -342,16 +342,20 @@ mod tests { #[test] fn read_only_filesystem_warning_includes_os_error_and_recovery_path() { + // Raw OS error 30 is EROFS on POSIX but ERROR_READ_FAULT on Windows, so + // assert the contract (the OS error text is surfaced verbatim) rather + // than the POSIX wording. + let os_error = std::io::Error::from_raw_os_error(30).to_string(); let warning = DownloadDirectoryFallback { kind: DownloadDirectoryKind::HuggingFaceXet, requested: PathBuf::from("/"), selected: PathBuf::from("/writable/data/huggingface/xet"), - error: std::io::Error::from_raw_os_error(30).to_string(), + error: os_error.clone(), raw_os_error: Some(30), }; let message = warning.to_string(); - assert!(message.contains("Read-only file system")); + assert!(message.contains(&os_error)); assert!(message.contains("/writable/data/huggingface/xet")); assert!(message.contains("HF_XET_CACHE")); assert!(message.contains("MESH_LLM_DATA_DIR")); From 065490f6fee7b438f443605abd7cee09df96ca45 Mon Sep 17 00:00:00 2001 From: Virgile Pourchet Date: Thu, 10 Sep 2026 19:02:21 +0200 Subject: [PATCH 24/41] feat(mesh): itemize the advertised capacity behind vram_bytes (#1673) * keep the memory block honest across relays, platforms and unified hosts * drop a memory block whose usable share exceeds the announced budget * read the per-device facts first when sizing the RAM-backed share --- .../src/api/tests/node_state.rs | 1 + .../src/api/tests/support.rs | 1 + .../src/inference/skippy/mod.rs | 2 +- .../src/inference/skippy/resolver.rs | 1 + .../skippy/resolver/hardware_tests.rs | 43 +++ .../src/inference/skippy/resolver/support.rs | 29 +- .../src/mesh/capacity.rs | 328 +++++++++++++++++- .../src/mesh/connections.rs | 2 + .../mesh-llm-host-runtime/src/mesh/gossip.rs | 14 + crates/mesh-llm-host-runtime/src/mesh/mod.rs | 1 + crates/mesh-llm-host-runtime/src/mesh/node.rs | 25 +- .../src/mesh/node/startup.rs | 21 ++ .../src/mesh/peer_state.rs | 6 + .../src/mesh/tests/admission/helpers.rs | 2 + .../src/mesh/tests/admission/requirements.rs | 3 + .../src/mesh/tests/connections.rs | 1 + .../src/mesh/tests/gossip.rs | 1 + .../src/mesh/tests/owner_control.rs | 1 + .../src/mesh/tests/peer_state.rs | 105 ++++++ .../src/mesh/tests/protocol_frames.rs | 1 + .../openai/ingress_tests/automatic_routing.rs | 1 + .../openai/moa_gateway/fleet_sim_tests.rs | 1 + .../network/openai/transport_tests/routing.rs | 1 + .../src/protocol/convert.rs | 53 ++- .../src/protocol/tests.rs | 1 + .../src/protocol/tests/announcements.rs | 208 +++++++++++ .../src/protocol/tests/mesh_timestamps.rs | 2 + .../src/runtime/local_split/test_support.rs | 1 + .../src/runtime_data/mod.rs | 2 + crates/mesh-llm-protocol/proto/node.proto | 15 + crates/mesh-llm-protocol/src/proto/node.rs | 31 ++ crates/mesh-llm-system/src/hardware/mod.rs | 56 ++- crates/mesh-llm-system/src/hardware/tests.rs | 85 ++++- docs/design/DESIGN.md | 5 +- docs/design/message_protocol.md | 5 +- docs/specs/vram-accounting.md | 1 + 36 files changed, 1019 insertions(+), 37 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/api/tests/node_state.rs b/crates/mesh-llm-host-runtime/src/api/tests/node_state.rs index b2c651c7e7..aa6d9ed3f7 100644 --- a/crates/mesh-llm-host-runtime/src/api/tests/node_state.rs +++ b/crates/mesh-llm-host-runtime/src/api/tests/node_state.rs @@ -103,6 +103,7 @@ fn make_test_state_peer(seed: u8, role: mesh::NodeRole) -> mesh::PeerInfo { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/api/tests/support.rs b/crates/mesh-llm-host-runtime/src/api/tests/support.rs index 64f3faae6a..f153555820 100644 --- a/crates/mesh-llm-host-runtime/src/api/tests/support.rs +++ b/crates/mesh-llm-host-runtime/src/api/tests/support.rs @@ -633,6 +633,7 @@ fn make_test_peer( is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/mod.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/mod.rs index c49e9f868b..840ac61f34 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/mod.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/mod.rs @@ -82,7 +82,7 @@ pub(crate) use package::{ }; pub(crate) use resolver::{ ResolvedEmbeddedOpenAiArgs, ResolvedSkippyConfig, SkippyConfigResolveRequest, - resolve_skippy_config_for_selector, + effective_safety_margin_bytes, resolve_skippy_config_for_selector, }; pub(crate) use skippy_server::OpenAiGuardrailsStatus as SkippyOpenAiGuardrailsStatus; pub(crate) use stage::admitted_resident_tensor_names; diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver.rs index 93894b7fdf..3ac3986bd2 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver.rs @@ -26,6 +26,7 @@ mod hardware_tests; #[cfg(test)] pub(crate) use resolution::resolve_skippy_config; pub(crate) use resolution::resolve_skippy_config_for_selector; +pub(crate) use support::effective_safety_margin_bytes; pub(crate) use types::{ ResolvedEmbeddedOpenAiArgs, ResolvedSkippyConfig, SkippyConfigResolveRequest, }; diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/hardware_tests.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/hardware_tests.rs index 7c2a4050b6..7c9b0681fe 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/hardware_tests.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/hardware_tests.rs @@ -53,3 +53,46 @@ placement = "auto" assert!(error.contains("defaults.hardware.placement")); } + +#[test] +fn effective_safety_margin_bytes_mirrors_the_fit_margin_rounding() { + use crate::plugin::{HardwareConfig, ModelConfigDefaults}; + + // Nothing configured: the built-in 2 GiB default the fit already applies. + assert_eq!(effective_safety_margin_bytes(None), 2 * 1024 * 1024 * 1024); + + let half_gib = ModelConfigDefaults { + hardware: Some(HardwareConfig { + safety_margin_gb: Some(0.5), + ..HardwareConfig::default() + }), + ..ModelConfigDefaults::default() + }; + assert_eq!( + effective_safety_margin_bytes(Some(&half_gib)), + 512 * 1024 * 1024 + ); + + let negative = ModelConfigDefaults { + hardware: Some(HardwareConfig { + safety_margin_gb: Some(-1.0), + ..HardwareConfig::default() + }), + ..ModelConfigDefaults::default() + }; + assert_eq!(effective_safety_margin_bytes(Some(&negative)), 0); +} + +#[test] +fn effective_safety_margin_bytes_saturates_on_absurd_margins() { + use crate::plugin::{HardwareConfig, ModelConfigDefaults}; + + let absurd = ModelConfigDefaults { + hardware: Some(HardwareConfig { + safety_margin_gb: Some(f64::MAX), + ..HardwareConfig::default() + }), + ..ModelConfigDefaults::default() + }; + assert_eq!(effective_safety_margin_bytes(Some(&absurd)), u64::MAX); +} diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/support.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/support.rs index 3f7d6730c9..b1716d2867 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/support.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/resolver/support.rs @@ -3,11 +3,12 @@ use skippy_protocol::{FlashAttentionType, StageKvCacheMode, StageKvCachePayload} use super::super::KvCachePolicy; use super::types::{ - BUILTIN_BATCH, BUILTIN_PARALLEL, BUILTIN_UBATCH, ResolvedStageKvCache, - ResolvedStageKvCacheTemplate, + BUILTIN_BATCH, BUILTIN_PARALLEL, BUILTIN_SAFETY_MARGIN_GB, BUILTIN_UBATCH, + ResolvedStageKvCache, ResolvedStageKvCacheTemplate, }; use crate::plugin::{ - BoolOrAuto, HardwareConfig, IntegerOrString, ModelFitConfig, SkippyConfig, StringOrStringList, + BoolOrAuto, HardwareConfig, IntegerOrString, ModelConfigDefaults, ModelFitConfig, SkippyConfig, + StringOrStringList, }; pub(super) fn derive_fit_target_mib( @@ -15,8 +16,26 @@ pub(super) fn derive_fit_target_mib( safety_margin_gb: f64, ) -> Option { let allocatable_mib = allocatable_memory_bytes?.checked_div(1024 * 1024)?; - let reserve_mib = (safety_margin_gb * 1024.0).round().max(0.0) as u64; - Some(allocatable_mib.saturating_sub(reserve_mib)) + Some(allocatable_mib.saturating_sub(safety_margin_mib(safety_margin_gb))) +} + +fn safety_margin_mib(safety_margin_gb: f64) -> u64 { + (safety_margin_gb * 1024.0).round().max(0.0) as u64 +} + +/// Bytes the local fit withholds on top of the driver reserve: the +/// `safety_margin_gb` from the global hardware defaults, or the built-in +/// default, rounded the way `derive_fit_target_mib` rounds it. The advertised +/// capacity breakdown reports this same value as the configured reserve, so +/// peers see the margin the fit actually applies. +pub(crate) fn effective_safety_margin_bytes(defaults: Option<&ModelConfigDefaults>) -> u64 { + let safety_margin_gb = defaults + .and_then(|defaults| defaults.hardware.as_ref()) + .and_then(|hardware| hardware.safety_margin_gb) + .unwrap_or(BUILTIN_SAFETY_MARGIN_GB); + // The float-to-integer cast saturates on absurd margins; saturate the + // byte conversion the same way instead of overflowing. + safety_margin_mib(safety_margin_gb).saturating_mul(1024 * 1024) } pub(super) fn effective_flash_attention(cache_type_v: &str) -> FlashAttentionType { diff --git a/crates/mesh-llm-host-runtime/src/mesh/capacity.rs b/crates/mesh-llm-host-runtime/src/mesh/capacity.rs index 1ace29d8a3..6c15334ed9 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/capacity.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/capacity.rs @@ -1,9 +1,7 @@ use crate::system::hardware::HardwareSurvey; pub(super) fn mesh_capacity_bytes(hw: &HardwareSurvey) -> u64 { - let unified_memory_only = - hw.is_soc && (hw.gpus.is_empty() || hw.gpus.iter().all(|gpu| gpu.unified_memory)); - if unified_memory_only { + if unified_memory_only(hw) { return hw.vram_bytes; } @@ -37,6 +35,13 @@ pub(super) fn mesh_capacity_bytes(hw: &HardwareSurvey) -> u64 { } } +/// A host whose accelerator memory is the system memory: its budget comes +/// from a platform working set (Metal) or a platform policy (the Tegra +/// collector), not from device VRAM minus a driver reserve. +fn unified_memory_only(hw: &HardwareSurvey) -> bool { + hw.is_soc && (hw.gpus.is_empty() || hw.gpus.iter().all(|gpu| gpu.unified_memory)) +} + pub(super) fn capped_capacity_bytes(capacity_bytes: u64, max_vram_gb: Option) -> u64 { max_vram_gb .map(|cap| capacity_bytes.min((cap * 1e9) as u64)) @@ -51,6 +56,105 @@ pub(super) fn advertised_capacity_bytes(hw: &HardwareSurvey, max_vram_gb: Option } } +/// Itemized view of the capacity a node advertises. The announcement's +/// `vram_bytes` stays the placement budget; this block explains how that +/// number was derived. Invariant: `total_bytes == reserved_bytes + +/// platform_reserve_bytes + configured_reserve_bytes + usable_bytes`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct AdvertisedMemory { + /// Enumerated accelerator memory: the sum of device VRAM, or the unified + /// working set on SoCs. + pub total_bytes: u64, + /// Driver/runtime reserved or unavailable bytes when the platform reports + /// a true value. + pub reserved_bytes: u64, + /// Withheld by platform policy before the owner configures anything: the + /// share of unified memory the platform keeps for the system (the Tegra + /// collector budgets 90% of physical RAM). Zero for discrete GPUs, whose + /// budget is the device memory minus the driver reserve, and zero on + /// Metal while the survey reports the working set as the device memory. + pub platform_reserve_bytes: u64, + /// Withheld by the node owner: the effective safety margin plus whatever + /// a `max_vram_gb` cap leaves out. + pub configured_reserve_bytes: u64, + /// What remains for mesh placement after both reserves. + pub usable_bytes: u64, + /// Total system RAM when the platform reports it. + pub system_ram_bytes: Option, + /// Portion of the local fit budget backed by system RAM, after any + /// `max_vram_gb` cap. Never advertised as accelerator capacity. + pub ram_offload_bytes: u64, +} + +pub(super) fn advertised_memory( + hw: &HardwareSurvey, + max_vram_gb: Option, + safety_margin_bytes: u64, +) -> AdvertisedMemory { + let (device_vram, driver_reserved) = enumerated_device_memory(hw); + let budget = advertised_capacity_bytes(hw, max_vram_gb); + // A host without enumerated accelerator memory only reaches a non-zero + // budget through an explicit `max_vram_gb` cap on its CPU budget; that + // bounded budget is then the whole of what it offers. + let total_bytes = if device_vram == 0 { + budget + } else { + device_vram + }; + let reserved_bytes = driver_reserved.min(total_bytes); + // On a unified-memory host the platform's own budget is a policy (Metal's + // working set, Tegra's 90% of RAM): whatever it keeps back from the + // enumerated memory is a platform reserve, not something the owner set. + // Discrete hosts budget the device memory minus the driver reserve, so + // nothing lands here. + let platform_reserve_bytes = if unified_memory_only(hw) { + total_bytes + .saturating_sub(reserved_bytes) + .saturating_sub(mesh_capacity_bytes(hw)) + } else { + 0 + }; + let owner_ceiling = total_bytes + .saturating_sub(reserved_bytes) + .saturating_sub(platform_reserve_bytes); + // The budget never exceeds what the platform leaves to the owner; the + // clamp only keeps the invariant if a survey ever reports otherwise. + let usable_bytes = budget + .saturating_sub(safety_margin_bytes) + .min(owner_ceiling); + let configured_reserve_bytes = owner_ceiling.saturating_sub(usable_bytes); + // The survey derives its RAM-backed share from the uncapped budget. A + // `max_vram_gb` cap shrinks the local budget first, and only what that + // capped budget still carries beyond the device memory is RAM. + let local_budget = capped_capacity_bytes(hw.vram_bytes, max_vram_gb); + let ram_offload_bytes = hw + .ram_offload_bytes + .min(local_budget.saturating_sub(device_vram)); + AdvertisedMemory { + total_bytes, + reserved_bytes, + platform_reserve_bytes, + configured_reserve_bytes, + usable_bytes, + system_ram_bytes: hw.system_ram_bytes, + ram_offload_bytes, + } +} + +/// Sum of the enumerated device memory and of the reserved bytes the platform +/// reported for it, with the same precedence as `mesh_capacity_bytes`: the +/// per-device facts first, the legacy per-GPU lists otherwise. +fn enumerated_device_memory(hw: &HardwareSurvey) -> (u64, u64) { + if !hw.gpus.is_empty() { + let vram = hw.gpus.iter().map(|gpu| gpu.vram_bytes).sum(); + let reserved = hw.gpus.iter().filter_map(|gpu| gpu.reserved_bytes).sum(); + return (vram, reserved); + } + let vram = hw.gpu_vram.iter().sum(); + let reserved = hw.gpu_reserved.iter().flatten().sum(); + (vram, reserved) +} + #[cfg(test)] mod tests { use super::*; @@ -76,7 +180,7 @@ mod tests { ..HardwareSurvey::default() }; - let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, None); + let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, None, 0); assert_eq!(snapshot.vram_bytes, 39_000_000_000); assert_eq!(snapshot.local_runtime_capacity_bytes, 491_000_000_000); @@ -93,7 +197,7 @@ mod tests { ..HardwareSurvey::default() }; - let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, None); + let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, None, 0); assert_eq!(snapshot.vram_bytes, 96_000_000_000); assert_eq!(snapshot.local_runtime_capacity_bytes, 96_000_000_000); @@ -107,7 +211,7 @@ mod tests { ..HardwareSurvey::default() }; - let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, None); + let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, None, 0); assert_eq!(snapshot.vram_bytes, 0); assert_eq!(snapshot.local_runtime_capacity_bytes, 491_000_000_000); @@ -121,7 +225,7 @@ mod tests { ..HardwareSurvey::default() }; - let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, Some(1.0)); + let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, Some(1.0), 0); assert_eq!(snapshot.vram_bytes, 1_000_000_000); assert_eq!(snapshot.local_runtime_capacity_bytes, 1_000_000_000); @@ -137,9 +241,217 @@ mod tests { ..HardwareSurvey::default() }; - let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, Some(32.0)); + let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, Some(32.0), 0); assert_eq!(snapshot.vram_bytes, 32_000_000_000); assert_eq!(snapshot.local_runtime_capacity_bytes, 32_000_000_000); } + + #[test] + fn discrete_gpu_breakdown_itemizes_driver_reserve_margin_and_offload() { + // 12 GB device, 0.5 GB driver reserve, 2 GB margin, 32 GB host: + // 9.5 GB usable, and the 18 GB RAM credit stays a local-only item. + let hw = HardwareSurvey { + vram_bytes: 30_000_000_000, + gpu_vram: vec![12_000_000_000], + gpu_reserved: vec![Some(500_000_000)], + gpus: vec![gpu(12_000_000_000, Some(500_000_000), false)], + system_ram_bytes: Some(32_000_000_000), + ram_offload_bytes: 18_000_000_000, + ..HardwareSurvey::default() + }; + + let memory = advertised_memory(&hw, None, 2_000_000_000); + + assert_eq!( + memory, + AdvertisedMemory { + total_bytes: 12_000_000_000, + reserved_bytes: 500_000_000, + platform_reserve_bytes: 0, + configured_reserve_bytes: 2_000_000_000, + usable_bytes: 9_500_000_000, + system_ram_bytes: Some(32_000_000_000), + ram_offload_bytes: 18_000_000_000, + } + ); + assert_breakdown_adds_up(&memory); + } + + #[test] + fn max_vram_cap_remainder_lands_in_the_configured_reserve() { + // 40 GB device, 1 GB driver reserve, capped at 32 GB, 2 GB margin: + // 30 GB usable, and the owner withholds the 7 GB cap remainder plus + // the 2 GB margin. + let hw = HardwareSurvey { + vram_bytes: 491_000_000_000, + gpu_vram: vec![40_000_000_000], + gpu_reserved: vec![Some(1_000_000_000)], + gpus: vec![gpu(40_000_000_000, Some(1_000_000_000), false)], + ..HardwareSurvey::default() + }; + + let memory = advertised_memory(&hw, Some(32.0), 2_000_000_000); + + assert_eq!(memory.total_bytes, 40_000_000_000); + assert_eq!(memory.reserved_bytes, 1_000_000_000); + assert_eq!(memory.configured_reserve_bytes, 9_000_000_000); + assert_eq!(memory.usable_bytes, 30_000_000_000); + assert_breakdown_adds_up(&memory); + } + + #[test] + fn max_vram_cap_bounds_the_ram_backed_share_of_the_local_budget() { + // 12 GB device credited to 30 GB with RAM: a 20 GB cap leaves 8 GB of + // the capped local budget beyond the device, a cap under the device + // memory leaves none. + let hw = HardwareSurvey { + vram_bytes: 30_000_000_000, + gpu_vram: vec![12_000_000_000], + gpu_reserved: vec![None], + gpus: vec![gpu(12_000_000_000, None, false)], + system_ram_bytes: Some(32_000_000_000), + ram_offload_bytes: 18_000_000_000, + ..HardwareSurvey::default() + }; + + assert_eq!( + advertised_memory(&hw, None, 0).ram_offload_bytes, + 18_000_000_000 + ); + assert_eq!( + advertised_memory(&hw, Some(20.0), 0).ram_offload_bytes, + 8_000_000_000 + ); + assert_eq!(advertised_memory(&hw, Some(8.0), 0).ram_offload_bytes, 0); + } + + #[test] + fn unified_memory_breakdown_reports_the_working_set_as_total() { + // The Metal survey reports the recommended working set as the device + // memory with no driver reserve, so only the margin is withheld. + let hw = HardwareSurvey { + vram_bytes: 96_000_000_000, + is_soc: true, + gpu_vram: vec![96_000_000_000], + gpu_reserved: vec![None], + gpus: vec![gpu(96_000_000_000, None, true)], + ..HardwareSurvey::default() + }; + + let memory = advertised_memory(&hw, None, 2_000_000_000); + + assert_eq!(memory.total_bytes, 96_000_000_000); + assert_eq!(memory.reserved_bytes, 0); + assert_eq!(memory.platform_reserve_bytes, 0); + assert_eq!(memory.configured_reserve_bytes, 2_000_000_000); + assert_eq!(memory.usable_bytes, 94_000_000_000); + assert_eq!(memory.ram_offload_bytes, 0); + assert_breakdown_adds_up(&memory); + } + + #[test] + fn tegra_shaped_survey_reports_the_platform_share_as_platform_reserve() { + // The Tegra collector reports physical RAM as the device memory and + // budgets 90% of it without a driver reserve: the 10% it keeps back + // is platform policy, not an owner setting. + let hw = HardwareSurvey { + vram_bytes: 57_600_000_000, + is_soc: true, + gpu_vram: vec![64_000_000_000], + gpu_reserved: Vec::new(), + system_ram_bytes: Some(64_000_000_000), + ..HardwareSurvey::default() + }; + + let memory = advertised_memory(&hw, None, 0); + + assert_eq!(memory.total_bytes, 64_000_000_000); + assert_eq!(memory.reserved_bytes, 0); + assert_eq!(memory.platform_reserve_bytes, 6_400_000_000); + assert_eq!(memory.configured_reserve_bytes, 0); + assert_eq!(memory.usable_bytes, 57_600_000_000); + assert_eq!(memory.ram_offload_bytes, 0); + assert_breakdown_adds_up(&memory); + + // The owner's margin and cap still land in the configured reserve, + // on top of the platform share. + let memory = advertised_memory(&hw, Some(32.0), 2_000_000_000); + + assert_eq!(memory.platform_reserve_bytes, 6_400_000_000); + assert_eq!(memory.usable_bytes, 30_000_000_000); + assert_eq!(memory.configured_reserve_bytes, 27_600_000_000); + assert_breakdown_adds_up(&memory); + } + + #[test] + fn cpu_only_host_without_cap_advertises_an_empty_breakdown() { + // No accelerator memory to itemize; the RAM-backed local budget is + // still reported, as an informational item. + let hw = HardwareSurvey { + vram_bytes: 24_000_000_000, + system_ram_bytes: Some(32_000_000_000), + ram_offload_bytes: 24_000_000_000, + ..HardwareSurvey::default() + }; + + let memory = advertised_memory(&hw, None, 2_000_000_000); + + assert_eq!( + memory, + AdvertisedMemory { + total_bytes: 0, + reserved_bytes: 0, + platform_reserve_bytes: 0, + configured_reserve_bytes: 0, + usable_bytes: 0, + system_ram_bytes: Some(32_000_000_000), + ram_offload_bytes: 24_000_000_000, + } + ); + } + + #[test] + fn explicit_cpu_budget_is_the_whole_total_of_a_cpu_only_host() { + // A 1 GB bounded budget under a 2 GB margin: nothing usable, and the + // whole bounded budget counts as withheld by the owner. + let hw = HardwareSurvey { + vram_bytes: 16_000_000_000, + ..HardwareSurvey::default() + }; + + let memory = advertised_memory(&hw, Some(1.0), 2_000_000_000); + + assert_eq!(memory.total_bytes, 1_000_000_000); + assert_eq!(memory.usable_bytes, 0); + assert_eq!(memory.configured_reserve_bytes, 1_000_000_000); + assert_breakdown_adds_up(&memory); + } + + #[test] + fn snapshot_carries_the_breakdown_next_to_the_budget() { + let hw = HardwareSurvey { + vram_bytes: 30_000_000_000, + gpu_vram: vec![12_000_000_000], + gpu_reserved: vec![Some(500_000_000)], + gpus: vec![gpu(12_000_000_000, Some(500_000_000), false)], + ..HardwareSurvey::default() + }; + + let snapshot = hardware_snapshot_for_start(hw, &NodeRole::Worker, None, 2_000_000_000); + + assert_eq!(snapshot.vram_bytes, 11_500_000_000); + assert_eq!(snapshot.memory.total_bytes, 12_000_000_000); + assert_eq!(snapshot.memory.usable_bytes, 9_500_000_000); + } + + fn assert_breakdown_adds_up(memory: &AdvertisedMemory) { + assert_eq!( + memory.total_bytes, + memory.reserved_bytes + + memory.platform_reserve_bytes + + memory.configured_reserve_bytes + + memory.usable_bytes + ); + } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/connections.rs b/crates/mesh-llm-host-runtime/src/mesh/connections.rs index 8ee63a6d15..fe6f9c94fa 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/connections.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/connections.rs @@ -14,6 +14,8 @@ pub(crate) struct NodeHardwareSnapshot { pub(crate) is_soc: Option, pub(crate) gpu_vram: Option, pub(crate) gpu_reserved_bytes: Option, + /// Itemized view of `vram_bytes`, announced alongside the GPU inventory. + pub(crate) memory: AdvertisedMemory, } pub(crate) struct OwnerRuntimeInit { diff --git a/crates/mesh-llm-host-runtime/src/mesh/gossip.rs b/crates/mesh-llm-host-runtime/src/mesh/gossip.rs index 3f2c18b193..d39a96f15f 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/gossip.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/gossip.rs @@ -289,6 +289,7 @@ pub(super) fn peer_meaningfully_changed(old: &PeerInfo, new: &PeerInfo) -> bool || old.version != new.version || old.owner_summary != new.owner_summary || old.gpu_reserved_bytes != new.gpu_reserved_bytes + || old.memory != new.memory || old.propagated_latency != new.propagated_latency || old.inference_admission_state != new.inference_admission_state } @@ -320,6 +321,7 @@ pub(super) fn apply_transitive_ann( existing.hosted_models_known = ann.hosted_models.is_some(); existing.role = ann.role.clone(); merge_first_joined_mesh_ts(&mut existing.first_joined_mesh_ts, ann.first_joined_mesh_ts); + let capacity_changed = existing.vram_bytes != ann.vram_bytes; existing.vram_bytes = ann.vram_bytes; // Only advance addr if the transitive announcement is at least as path-rich, // so a direct peer's richer address is not overwritten by a weaker transitive one. @@ -344,6 +346,15 @@ pub(super) fn apply_transitive_ann( if ann.gpu_reserved_bytes.is_some() { existing.gpu_reserved_bytes = ann.gpu_reserved_bytes.clone(); } + match ann.memory { + Some(memory) => existing.memory = Some(memory), + // A relay that predates the block strips it. The cached block only + // explains the capacity it arrived with: keep it while that capacity + // is unchanged, drop it once the capacity moved, so a stale breakdown + // is never paired with the new budget and rebroadcast as such. + None if capacity_changed => existing.memory = None, + None => {} + } if ann.gpu_mem_bandwidth_gbps.is_some() { existing.gpu_mem_bandwidth_gbps = ann.gpu_mem_bandwidth_gbps.clone(); } @@ -835,6 +846,7 @@ impl Node { existing.is_soc = ann.is_soc; existing.gpu_vram = ann.gpu_vram.clone(); existing.gpu_reserved_bytes = ann.gpu_reserved_bytes.clone(); + existing.memory = ann.memory; existing.gpu_mem_bandwidth_gbps = ann.gpu_mem_bandwidth_gbps.clone(); existing.gpu_compute_tflops_fp32 = ann.gpu_compute_tflops_fp32.clone(); existing.gpu_compute_tflops_fp16 = ann.gpu_compute_tflops_fp16.clone(); @@ -1175,6 +1187,7 @@ impl Node { is_soc: peer.is_soc, gpu_vram: peer.gpu_vram.clone(), gpu_reserved_bytes: peer.gpu_reserved_bytes.clone(), + memory: peer.memory, gpu_mem_bandwidth_gbps: peer.gpu_mem_bandwidth_gbps.clone(), gpu_compute_tflops_fp32: peer.gpu_compute_tflops_fp32.clone(), gpu_compute_tflops_fp16: peer.gpu_compute_tflops_fp16.clone(), @@ -1242,6 +1255,7 @@ impl Node { .enumerate_host .then(|| self.gpu_reserved_bytes.clone()) .flatten(), + memory: self.enumerate_host.then_some(self.advertised_memory), gpu_mem_bandwidth_gbps: data.gpu_mem_bandwidth_gbps, gpu_compute_tflops_fp32: data.gpu_compute_tflops_fp32, gpu_compute_tflops_fp16: data.gpu_compute_tflops_fp16, diff --git a/crates/mesh-llm-host-runtime/src/mesh/mod.rs b/crates/mesh-llm-host-runtime/src/mesh/mod.rs index 93a1361980..9c940e1014 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/mod.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/mod.rs @@ -132,6 +132,7 @@ use stage_artifacts::*; use stage_transport::*; use stun::*; +pub use capacity::AdvertisedMemory; pub use connections::{QuicBindSelection, RelayConfig, RelayPolicy}; pub(crate) use connectivity::MeshConnectivitySnapshot; pub use gossip::backfill_legacy_descriptors; diff --git a/crates/mesh-llm-host-runtime/src/mesh/node.rs b/crates/mesh-llm-host-runtime/src/mesh/node.rs index 38d6fa1496..d28bbd4664 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/node.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/node.rs @@ -7,12 +7,13 @@ mod routing_telemetry; mod startup; pub use startup::detect_vram_bytes_capped; +#[cfg(test)] +pub(crate) use startup::hardware_snapshot_for_start; use startup::{ - bind_mesh_endpoint, init_owner_runtime, startup_secret_key, wait_for_endpoint_online, -}; -pub(crate) use startup::{ - default_plugin_event_source, hardware_snapshot_for_start, startup_transport_config, + advertised_hardware_for_start, bind_mesh_endpoint, init_owner_runtime, startup_secret_key, + wait_for_endpoint_online, }; +pub(crate) use startup::{default_plugin_event_source, startup_transport_config}; /// Upper bound on how long shutdown waits for one iroh endpoint to close. /// @@ -160,6 +161,8 @@ pub struct Node { pub is_soc: Option, pub gpu_vram: Option, pub gpu_reserved_bytes: Option, + /// Itemized view of the advertised capacity, sent with the GPU inventory. + pub advertised_memory: AdvertisedMemory, pub gpu_mem_bandwidth_gbps: Arc>>>, pub gpu_compute_tflops_fp32: Arc>>>, pub gpu_compute_tflops_fp16: Arc>>>, @@ -742,8 +745,13 @@ impl Node { let (tunnel_http_tx, tunnel_http_rx) = tokio::sync::mpsc::channel(256); let (stage_transport_tx, stage_transport_rx) = tokio::sync::mpsc::channel(256); + let config_state_init = { + let path = crate::plugin::config_path(config_path) + .unwrap_or_else(|_| std::path::PathBuf::from("config.toml")); + crate::runtime::config_state::ConfigState::load(&path)? + }; let hardware = - hardware_snapshot_for_start(crate::system::hardware::survey(), &role, max_vram_gb); + advertised_hardware_for_start(config_state_init.config(), &role, max_vram_gb); let owner_runtime = init_owner_runtime( owner_config.as_ref(), endpoint.id(), @@ -756,11 +764,6 @@ impl Node { TrustPolicy::Off, current_time_unix_ms(), ); - let config_state_init = { - let path = crate::plugin::config_path(config_path) - .unwrap_or_else(|_| std::path::PathBuf::from("config.toml")); - crate::runtime::config_state::ConfigState::load(&path)? - }; let config_revision_init = config_state_init.revision(); let runtime_data_collector = crate::runtime_data::RuntimeDataCollector::new(); let runtime_data_producer = @@ -878,6 +881,7 @@ impl Node { is_soc: hardware.is_soc, gpu_vram: hardware.gpu_vram, gpu_reserved_bytes: hardware.gpu_reserved_bytes, + advertised_memory: hardware.memory, gpu_mem_bandwidth_gbps: Arc::new(tokio::sync::Mutex::new(None)), gpu_compute_tflops_fp32: Arc::new(tokio::sync::Mutex::new(None)), gpu_compute_tflops_fp16: Arc::new(tokio::sync::Mutex::new(None)), @@ -1022,6 +1026,7 @@ impl Node { )), vram_bytes: 0, local_runtime_capacity_bytes: 0, + advertised_memory: AdvertisedMemory::default(), peer_change_tx, peer_change_rx, inflight_requests: Arc::new(std::sync::atomic::AtomicUsize::new(0)), diff --git a/crates/mesh-llm-host-runtime/src/mesh/node/startup.rs b/crates/mesh-llm-host-runtime/src/mesh/node/startup.rs index ca22e261ff..2c87ae31de 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/node/startup.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/node/startup.rs @@ -103,14 +103,34 @@ pub(super) async fn wait_for_endpoint_online( } } +/// Surveys the host and derives the capacity this node will advertise. The +/// advertised reserve mirrors what the local fit withholds, so the itemized +/// capacity and the fit target agree on the configured margin. +pub(super) fn advertised_hardware_for_start( + config: &crate::plugin::MeshConfig, + role: &NodeRole, + max_vram_gb: Option, +) -> NodeHardwareSnapshot { + let safety_margin_bytes = + crate::inference::skippy::effective_safety_margin_bytes(config.defaults.as_ref()); + hardware_snapshot_for_start( + crate::system::hardware::survey(), + role, + max_vram_gb, + safety_margin_bytes, + ) +} + pub(crate) fn hardware_snapshot_for_start( hw: crate::system::hardware::HardwareSurvey, role: &NodeRole, max_vram_gb: Option, + safety_margin_bytes: u64, ) -> NodeHardwareSnapshot { let local_runtime_capacity_bytes = super::super::capacity::capped_capacity_bytes(hw.vram_bytes, max_vram_gb); let mut vram_bytes = super::super::capacity::advertised_capacity_bytes(&hw, max_vram_gb); + let memory = super::super::capacity::advertised_memory(&hw, max_vram_gb, safety_margin_bytes); let gpu_name = if matches!(role, NodeRole::Client) { None } else { @@ -147,6 +167,7 @@ pub(crate) fn hardware_snapshot_for_start( is_soc, gpu_vram, gpu_reserved_bytes, + memory, } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs b/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs index 68855df71c..a07b27caa0 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs @@ -139,6 +139,9 @@ pub struct PeerAnnouncement { pub(crate) is_soc: Option, pub(crate) gpu_vram: Option, pub(crate) gpu_reserved_bytes: Option, + /// Itemized view of `vram_bytes`; absent from peers that predate it or + /// that do not enumerate their hardware. + pub(crate) memory: Option, pub(crate) gpu_mem_bandwidth_gbps: Option, pub(crate) gpu_compute_tflops_fp32: Option, pub(crate) gpu_compute_tflops_fp16: Option, @@ -243,6 +246,8 @@ pub struct PeerInfo { pub is_soc: Option, pub gpu_vram: Option, pub gpu_reserved_bytes: Option, + /// Itemized view of `vram_bytes` when the peer advertised one. + pub memory: Option, pub gpu_mem_bandwidth_gbps: Option, pub gpu_compute_tflops_fp32: Option, pub gpu_compute_tflops_fp16: Option, @@ -331,6 +336,7 @@ impl PeerInfo { is_soc: ann.is_soc, gpu_vram: ann.gpu_vram.clone(), gpu_reserved_bytes: ann.gpu_reserved_bytes.clone(), + memory: ann.memory, gpu_mem_bandwidth_gbps: ann.gpu_mem_bandwidth_gbps.clone(), gpu_compute_tflops_fp32: ann.gpu_compute_tflops_fp32.clone(), gpu_compute_tflops_fp16: ann.gpu_compute_tflops_fp16.clone(), diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs index 7c0ed22183..6500b10cc1 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs @@ -29,6 +29,7 @@ pub(super) fn make_test_peer(id: EndpointId, rtt_ms: Option, vram_gb: u64) is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -403,6 +404,7 @@ pub(super) fn requirement_peer_announcement( is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs index e83732a6be..b6fce098a5 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs @@ -577,6 +577,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_untrusted_release_signer is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -674,6 +675,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_invalid_release_attestat is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -758,6 +760,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_wrong_mesh_id() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs index 96aeb5b9ee..352435218c 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/connections.rs @@ -463,6 +463,7 @@ async fn make_test_node_with_requirements( is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + advertised_memory: crate::mesh::AdvertisedMemory::default(), gpu_mem_bandwidth_gbps: Arc::new(tokio::sync::Mutex::new(None)), gpu_compute_tflops_fp32: Arc::new(tokio::sync::Mutex::new(None)), gpu_compute_tflops_fp16: Arc::new(tokio::sync::Mutex::new(None)), diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs index b79cad646a..56c575fbb4 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs @@ -40,6 +40,7 @@ pub(crate) fn test_announcement(ts: Option) -> PeerAnnouncement { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/owner_control.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/owner_control.rs index 714e9b07c6..42e932fdf8 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/owner_control.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/owner_control.rs @@ -29,6 +29,7 @@ fn make_test_peer_info(peer_id: EndpointId) -> PeerInfo { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs index f7cc453abb..64f0e2bf6d 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs @@ -379,6 +379,7 @@ fn peer_state_test_announcement(addr: EndpointAddr) -> super::PeerAnnouncement { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -983,6 +984,7 @@ fn gossip_frame_roundtrip_preserves_scanned_model_metadata() { is_soc: Some(true), gpu_vram: Some("128 GB".to_string()), gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -1365,6 +1367,7 @@ fn transitive_peer_update_refreshes_metadata_fields() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -1458,6 +1461,7 @@ fn transitive_peer_merge_preserves_richer_direct_address() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -1525,6 +1529,7 @@ fn transitive_peer_merge_preserves_richer_direct_address() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -1831,3 +1836,103 @@ async fn connectivity_snapshot_does_not_treat_admitted_membership_as_connected() } ); } + +#[test] +fn transitive_peer_update_refreshes_memory_only_when_advertised() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0x11; 32]).public()); + let advertised = crate::mesh::AdvertisedMemory { + total_bytes: 12_000_000_000, + reserved_bytes: 0, + platform_reserve_bytes: 0, + configured_reserve_bytes: 2_000_000_000, + usable_bytes: 10_000_000_000, + system_ram_bytes: None, + ram_offload_bytes: 0, + }; + let mut existing = make_test_peer_info(peer_id); + existing.memory = Some(advertised); + + let addr = EndpointAddr { + id: peer_id, + addrs: Default::default(), + }; + let mut ann = peer_state_test_announcement(addr.clone()); + apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); + assert_eq!( + existing.memory, + Some(advertised), + "a relay without the block keeps the last advertised one" + ); + + let refreshed = crate::mesh::AdvertisedMemory { + configured_reserve_bytes: 3_000_000_000, + usable_bytes: 9_000_000_000, + ..advertised + }; + ann.memory = Some(refreshed); + apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); + assert_eq!(existing.memory, Some(refreshed)); +} + +#[test] +fn transitive_peer_update_drops_the_cached_memory_when_the_capacity_moves() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0x12; 32]).public()); + let advertised = crate::mesh::AdvertisedMemory { + total_bytes: 12_000_000_000, + reserved_bytes: 0, + platform_reserve_bytes: 0, + configured_reserve_bytes: 2_000_000_000, + usable_bytes: 10_000_000_000, + system_ram_bytes: None, + ram_offload_bytes: 0, + }; + let mut existing = make_test_peer_info(peer_id); + existing.vram_bytes = 10_000_000_000; + existing.memory = Some(advertised); + + let addr = EndpointAddr { + id: peer_id, + addrs: Default::default(), + }; + // An older relay strips the block and carries a new cap: the cached block + // explained the old budget, so it must not travel with the new one. + let mut ann = peer_state_test_announcement(addr.clone()); + ann.vram_bytes = 8_000_000_000; + apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); + assert_eq!(existing.vram_bytes, 8_000_000_000); + assert_eq!( + existing.memory, None, + "a stale breakdown must not be paired with a new capacity" + ); + + // The same relay with the unchanged capacity keeps the block. + existing.memory = Some(advertised); + apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); + assert_eq!(existing.memory, Some(advertised)); +} + +#[test] +fn peer_meaningfully_changed_detects_memory_updates() { + let peer_id = make_test_endpoint_id(13); + let mut old_peer = make_test_peer_info(peer_id); + let mut new_peer = old_peer.clone(); + + let advertised = crate::mesh::AdvertisedMemory { + total_bytes: 12_000_000_000, + reserved_bytes: 0, + platform_reserve_bytes: 0, + configured_reserve_bytes: 2_000_000_000, + usable_bytes: 10_000_000_000, + system_ram_bytes: None, + ram_offload_bytes: 0, + }; + old_peer.memory = Some(advertised); + new_peer.memory = Some(crate::mesh::AdvertisedMemory { + configured_reserve_bytes: 3_000_000_000, + usable_bytes: 9_000_000_000, + ..advertised + }); + + assert!(peer_meaningfully_changed(&old_peer, &new_peer)); + assert!(!peer_meaningfully_changed(&old_peer, &old_peer.clone())); +} diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs index a69e6b708c..5f0b79d76e 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs @@ -299,6 +299,7 @@ async fn transitive_peer_update_refreshes_last_mentioned() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/network/openai/ingress_tests/automatic_routing.rs b/crates/mesh-llm-host-runtime/src/network/openai/ingress_tests/automatic_routing.rs index d848e52b7d..76be59f11e 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/ingress_tests/automatic_routing.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/ingress_tests/automatic_routing.rs @@ -354,6 +354,7 @@ fn peer_serving(peer_id: iroh::EndpointId, model: &str, vision: bool) -> mesh::P is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs index 7016478242..85e86a7b5d 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/fleet_sim_tests.rs @@ -102,6 +102,7 @@ pub(super) fn fleet_peer(seed: u32, model: FleetModel) -> mesh::PeerInfo { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs b/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs index 8813daa4d8..7b6a8082b6 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/transport_tests/routing.rs @@ -96,6 +96,7 @@ fn test_peer_serving_model(peer_id: iroh::EndpointId, model: &str) -> mesh::Peer is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/protocol/convert.rs b/crates/mesh-llm-host-runtime/src/protocol/convert.rs index c87fffd2c7..c905079653 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/convert.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/convert.rs @@ -414,17 +414,61 @@ fn local_hardware_info_to_proto( ann: &PeerAnnouncement, ) -> Option { let gpus = local_gpu_info_to_proto(ann); - if ann.hostname.is_none() && ann.is_soc.is_none() && gpus.is_empty() { + let memory = ann.memory.as_ref().map(local_memory_to_proto); + if ann.hostname.is_none() && ann.is_soc.is_none() && gpus.is_empty() && memory.is_none() { None } else { Some(crate::proto::node::HardwareInfo { is_soc: ann.is_soc, hostname: ann.hostname.clone(), gpus, + memory, }) } } +fn local_memory_to_proto(memory: &crate::mesh::AdvertisedMemory) -> crate::proto::node::MemoryInfo { + crate::proto::node::MemoryInfo { + total_bytes: Some(memory.total_bytes), + reserved_bytes: Some(memory.reserved_bytes), + configured_reserve_bytes: Some(memory.configured_reserve_bytes), + usable_bytes: Some(memory.usable_bytes), + system_ram_bytes: memory.system_ram_bytes, + ram_offload_bytes: Some(memory.ram_offload_bytes), + platform_reserve_bytes: Some(memory.platform_reserve_bytes), + } +} + +/// A peer that itemizes its capacity always sends the full partition. A block +/// missing its total or usable share, or whose reserves and usable share do +/// not add up to its total, carries nothing the breakdown can trust and is +/// dropped. +fn proto_memory_to_local( + memory: &crate::proto::node::MemoryInfo, +) -> Option { + let total_bytes = memory.total_bytes?; + let usable_bytes = memory.usable_bytes?; + let reserved_bytes = memory.reserved_bytes.unwrap_or(0); + let platform_reserve_bytes = memory.platform_reserve_bytes.unwrap_or(0); + let configured_reserve_bytes = memory.configured_reserve_bytes.unwrap_or(0); + let partition = reserved_bytes + .checked_add(platform_reserve_bytes)? + .checked_add(configured_reserve_bytes)? + .checked_add(usable_bytes)?; + if partition != total_bytes { + return None; + } + Some(crate::mesh::AdvertisedMemory { + total_bytes, + reserved_bytes, + platform_reserve_bytes, + configured_reserve_bytes, + usable_bytes, + system_ram_bytes: memory.system_ram_bytes, + ram_offload_bytes: memory.ram_offload_bytes.unwrap_or(0), + }) +} + struct LegacyGpuFields { gpu_name: Option, gpu_vram: Option, @@ -947,6 +991,13 @@ pub(crate) fn proto_ann_to_local( gpu_reserved_bytes: legacy_gpu_fields .gpu_reserved_bytes .or_else(|| pa.gpu_reserved_bytes.clone()), + // The block explains the budget it travels with; a usable share + // larger than that budget contradicts it and is dropped, so it can + // neither be shown nor rebroadcast. + memory: hardware + .and_then(|hardware| hardware.memory.as_ref()) + .and_then(proto_memory_to_local) + .filter(|memory| memory.usable_bytes <= pa.vram_bytes), gpu_mem_bandwidth_gbps: legacy_gpu_fields .gpu_mem_bandwidth_gbps .or_else(|| pa.gpu_mem_bandwidth_gbps.clone()), diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests.rs b/crates/mesh-llm-host-runtime/src/protocol/tests.rs index 15a52254cf..337daa9e3b 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests.rs @@ -163,6 +163,7 @@ fn make_test_peer_info(peer_id: EndpointId) -> PeerInfo { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs index c7bb2fede6..0817fbdd90 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs @@ -27,6 +27,7 @@ fn owner_fields_roundtrip_through_proto_announcement() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -167,6 +168,7 @@ fn advertised_model_throughput_roundtrips_through_proto_announcement() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -375,6 +377,7 @@ fn inference_admission_state_roundtrips_through_proto_announcement() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -629,6 +632,7 @@ fn test_proto_round_trip_with_bandwidth_and_tflops() { is_soc: Some(false), gpu_vram: Some("51539607552".to_string()), gpu_reserved_bytes: Some("1073741824".to_string()), + memory: None, gpu_mem_bandwidth_gbps: Some("1948.70".to_string()), gpu_compute_tflops_fp32: Some("19.50".to_string()), gpu_compute_tflops_fp16: Some("312.00".to_string()), @@ -713,6 +717,7 @@ fn test_proto_backward_compat_missing_tflops() { gpu_name: Some("NVIDIA A100".to_string()), gpu_vram: Some("51539607552".to_string()), hardware: Some(crate::proto::node::HardwareInfo { + memory: None, is_soc: Some(false), hostname: None, gpus: vec![crate::proto::node::GpuInfo { @@ -744,6 +749,7 @@ fn test_proto_gpu_info_preserves_legacy_fields_for_old_consumers() { endpoint_id: peer_id.as_bytes().to_vec(), role: NodeRole::Worker as i32, hardware: Some(crate::proto::node::HardwareInfo { + memory: None, is_soc: Some(false), hostname: Some("worker-01".to_string()), gpus: vec![ @@ -793,3 +799,205 @@ fn test_proto_gpu_info_preserves_legacy_fields_for_old_consumers() { ); assert_eq!(roundtripped.is_soc, Some(false)); } + +#[test] +fn advertised_memory_roundtrips_through_proto_announcement() { + let memory = crate::mesh::AdvertisedMemory { + total_bytes: 12_000_000_000, + reserved_bytes: 500_000_000, + platform_reserve_bytes: 0, + configured_reserve_bytes: 2_000_000_000, + usable_bytes: 9_500_000_000, + system_ram_bytes: Some(32_000_000_000), + ram_offload_bytes: 18_000_000_000, + }; + // Start from a bare announcement with no GPU inventory, hostname or SoC + // flag: the memory block alone must carry the hardware envelope onto the + // wire. + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD0; 32]).public()); + let bare = crate::proto::node::PeerAnnouncement { + endpoint_id: peer_id.as_bytes().to_vec(), + role: NodeRole::Worker as i32, + vram_bytes: 9_500_000_000, + ..Default::default() + }; + let (_, mut ann) = proto_ann_to_local(&bare).expect("proto_ann_to_local must succeed"); + assert_eq!(ann.memory, None); + ann.memory = Some(memory); + + let proto_pa = local_ann_to_proto_ann(&ann); + let wire = proto_pa + .hardware + .as_ref() + .and_then(|hardware| hardware.memory.as_ref()) + .expect("memory block must be on the wire"); + assert_eq!(wire.total_bytes, Some(12_000_000_000)); + assert_eq!(wire.reserved_bytes, Some(500_000_000)); + assert_eq!(wire.configured_reserve_bytes, Some(2_000_000_000)); + assert_eq!(wire.usable_bytes, Some(9_500_000_000)); + assert_eq!(wire.system_ram_bytes, Some(32_000_000_000)); + assert_eq!(wire.ram_offload_bytes, Some(18_000_000_000)); + assert_eq!(wire.platform_reserve_bytes, Some(0)); + + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.memory, Some(memory)); +} + +#[test] +fn platform_reserve_roundtrips_and_counts_in_the_partition() { + // A Tegra-shaped block: 64 GB total, 6.4 GB kept back by the platform, + // the rest usable, nothing configured by the owner. + let memory = crate::mesh::AdvertisedMemory { + total_bytes: 64_000_000_000, + reserved_bytes: 0, + platform_reserve_bytes: 6_400_000_000, + configured_reserve_bytes: 0, + usable_bytes: 57_600_000_000, + system_ram_bytes: Some(64_000_000_000), + ram_offload_bytes: 0, + }; + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD1; 32]).public()); + let bare = crate::proto::node::PeerAnnouncement { + endpoint_id: peer_id.as_bytes().to_vec(), + role: NodeRole::Worker as i32, + vram_bytes: 57_600_000_000, + ..Default::default() + }; + let (_, mut ann) = proto_ann_to_local(&bare).expect("proto_ann_to_local must succeed"); + ann.memory = Some(memory); + + let proto_pa = local_ann_to_proto_ann(&ann); + let wire = proto_pa + .hardware + .as_ref() + .and_then(|hardware| hardware.memory.as_ref()) + .expect("memory block must be on the wire"); + assert_eq!(wire.platform_reserve_bytes, Some(6_400_000_000)); + + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.memory, Some(memory)); + + // The same block without the platform share no longer adds up and is + // dropped, so a peer cannot quietly move that share into usable. + let mut stripped = proto_pa.clone(); + if let Some(block) = stripped + .hardware + .as_mut() + .and_then(|hardware| hardware.memory.as_mut()) + { + block.platform_reserve_bytes = None; + } + let (_, dropped) = proto_ann_to_local(&stripped).expect("proto_ann_to_local must succeed"); + assert_eq!(dropped.memory, None); +} + +#[test] +fn malformed_memory_blocks_are_dropped_at_ingest() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xCF; 32]).public()); + let announce = |memory: crate::proto::node::MemoryInfo| crate::proto::node::PeerAnnouncement { + endpoint_id: peer_id.as_bytes().to_vec(), + role: NodeRole::Worker as i32, + vram_bytes: 12_000_000_000, + hardware: Some(crate::proto::node::HardwareInfo { + is_soc: Some(false), + hostname: None, + gpus: vec![], + memory: Some(memory), + }), + ..Default::default() + }; + + let missing_total = announce(crate::proto::node::MemoryInfo { + usable_bytes: Some(9_500_000_000), + ..Default::default() + }); + let (_, ann) = proto_ann_to_local(&missing_total).expect("proto_ann_to_local must succeed"); + assert_eq!(ann.memory, None, "a block without its total is dropped"); + + let inverted = announce(crate::proto::node::MemoryInfo { + total_bytes: Some(12_000_000_000), + usable_bytes: Some(13_000_000_000), + ..Default::default() + }); + let (_, ann) = proto_ann_to_local(&inverted).expect("proto_ann_to_local must succeed"); + assert_eq!(ann.memory, None, "more usable than total is dropped"); + + let unbalanced = announce(crate::proto::node::MemoryInfo { + total_bytes: Some(12_000_000_000), + reserved_bytes: Some(10_000_000_000), + configured_reserve_bytes: Some(10_000_000_000), + usable_bytes: Some(1_000_000_000), + ..Default::default() + }); + let (_, ann) = proto_ann_to_local(&unbalanced).expect("proto_ann_to_local must succeed"); + assert_eq!( + ann.memory, None, + "reserves and usable share that do not add up to the total are dropped" + ); + + let overflowing = announce(crate::proto::node::MemoryInfo { + total_bytes: Some(u64::MAX), + reserved_bytes: Some(u64::MAX), + configured_reserve_bytes: Some(u64::MAX), + usable_bytes: Some(u64::MAX), + ..Default::default() + }); + let (_, ann) = proto_ann_to_local(&overflowing).expect("proto_ann_to_local must succeed"); + assert_eq!(ann.memory, None, "a partition that overflows is dropped"); + + let minimal = announce(crate::proto::node::MemoryInfo { + total_bytes: Some(12_000_000_000), + usable_bytes: Some(12_000_000_000), + ..Default::default() + }); + let (_, ann) = proto_ann_to_local(&minimal).expect("proto_ann_to_local must succeed"); + assert_eq!( + ann.memory, + Some(crate::mesh::AdvertisedMemory { + total_bytes: 12_000_000_000, + reserved_bytes: 0, + platform_reserve_bytes: 0, + configured_reserve_bytes: 0, + usable_bytes: 12_000_000_000, + system_ram_bytes: None, + ram_offload_bytes: 0, + }), + "absent reserves count as zero when the partition still adds up" + ); +} + +#[test] +fn memory_block_exceeding_the_placement_budget_is_dropped_at_ingest() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD2; 32]).public()); + let announce = |vram_bytes: u64| crate::proto::node::PeerAnnouncement { + endpoint_id: peer_id.as_bytes().to_vec(), + role: NodeRole::Worker as i32, + vram_bytes, + hardware: Some(crate::proto::node::HardwareInfo { + is_soc: Some(false), + hostname: None, + gpus: vec![], + memory: Some(crate::proto::node::MemoryInfo { + total_bytes: Some(12_000_000_000), + usable_bytes: Some(12_000_000_000), + ..Default::default() + }), + }), + ..Default::default() + }; + + // The block explains the budget it travels with: more usable than the + // announced budget is a contradiction, so the block is dropped and the + // budget itself is kept. + let (_, ann) = + proto_ann_to_local(&announce(9_500_000_000)).expect("proto_ann_to_local must succeed"); + assert_eq!(ann.memory, None); + assert_eq!(ann.vram_bytes, 9_500_000_000); + + let (_, ann) = + proto_ann_to_local(&announce(12_000_000_000)).expect("proto_ann_to_local must succeed"); + assert!( + ann.memory.is_some(), + "usable equal to the budget is consistent" + ); +} diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs b/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs index da2bdda393..91b0ddadc6 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs @@ -31,6 +31,7 @@ fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -89,6 +90,7 @@ fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/runtime/local_split/test_support.rs b/crates/mesh-llm-host-runtime/src/runtime/local_split/test_support.rs index 11012c3310..dacb33432a 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/local_split/test_support.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/local_split/test_support.rs @@ -162,6 +162,7 @@ pub(super) fn split_test_peer( is_soc: None, gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-host-runtime/src/runtime_data/mod.rs b/crates/mesh-llm-host-runtime/src/runtime_data/mod.rs index 1adbb9a93a..eec98c0712 100644 --- a/crates/mesh-llm-host-runtime/src/runtime_data/mod.rs +++ b/crates/mesh-llm-host-runtime/src/runtime_data/mod.rs @@ -527,6 +527,7 @@ pub(crate) mod tests { is_soc: Some(false), gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, @@ -670,6 +671,7 @@ pub(crate) mod tests { is_soc: Some(false), gpu_vram: None, gpu_reserved_bytes: None, + memory: None, gpu_mem_bandwidth_gbps: None, gpu_compute_tflops_fp32: None, gpu_compute_tflops_fp16: None, diff --git a/crates/mesh-llm-protocol/proto/node.proto b/crates/mesh-llm-protocol/proto/node.proto index 95437a4260..7a2465f2a2 100644 --- a/crates/mesh-llm-protocol/proto/node.proto +++ b/crates/mesh-llm-protocol/proto/node.proto @@ -201,6 +201,7 @@ message HardwareInfo { optional bool is_soc = 1; // True for system-on-chip / unified-memory hosts such as Apple Silicon and Jetson optional string hostname = 2; repeated GpuInfo gpus = 3; + optional MemoryInfo memory = 4; // Introduced in v0.77.0; itemizes the capacity behind PeerAnnouncement.vram_bytes } message GpuInfo { @@ -212,6 +213,20 @@ message GpuInfo { optional string compute_tflops_fp16 = 6; } +// Itemized capacity behind `PeerAnnouncement.vram_bytes`. Additive and +// informational: `vram_bytes` stays the placement budget, this block explains +// how it was derived. Invariant: total_bytes = reserved_bytes + +// platform_reserve_bytes + configured_reserve_bytes + usable_bytes. +message MemoryInfo { + optional uint64 total_bytes = 1; // Enumerated accelerator memory (sum of device VRAM; the unified working set on SoCs) + optional uint64 reserved_bytes = 2; // Driver/runtime reserved or unavailable bytes, when the platform reports a true value + optional uint64 configured_reserve_bytes = 3; // Withheld by the node owner: the effective safety margin plus any max_vram cap remainder + optional uint64 usable_bytes = 4; // What remains for mesh placement after both reserves + optional uint64 system_ram_bytes = 5; // Total system RAM, when the platform reports it + optional uint64 ram_offload_bytes = 6; // Portion of the node's local fit budget backed by system RAM; never advertised as accelerator capacity + optional uint64 platform_reserve_bytes = 7; // Withheld by platform policy on unified-memory hosts (for example the 10% of RAM the Tegra collector keeps back); zero for discrete GPUs +} + message SignedNodeOwnership { uint32 version = 1; string cert_id = 2; diff --git a/crates/mesh-llm-protocol/src/proto/node.rs b/crates/mesh-llm-protocol/src/proto/node.rs index f273729644..d812ee6420 100644 --- a/crates/mesh-llm-protocol/src/proto/node.rs +++ b/crates/mesh-llm-protocol/src/proto/node.rs @@ -209,6 +209,9 @@ pub struct HardwareInfo { pub hostname: ::core::option::Option<::prost::alloc::string::String>, #[prost(message, repeated, tag = "3")] pub gpus: ::prost::alloc::vec::Vec, + /// Introduced in v0.77.0; itemizes the capacity behind PeerAnnouncement.vram_bytes + #[prost(message, optional, tag = "4")] + pub memory: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct GpuInfo { @@ -225,6 +228,34 @@ pub struct GpuInfo { #[prost(string, optional, tag = "6")] pub compute_tflops_fp16: ::core::option::Option<::prost::alloc::string::String>, } +/// Itemized capacity behind `PeerAnnouncement.vram_bytes`. Additive and +/// informational: `vram_bytes` stays the placement budget, this block explains +/// how it was derived. Invariant: total_bytes = reserved_bytes + +/// platform_reserve_bytes + configured_reserve_bytes + usable_bytes. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct MemoryInfo { + /// Enumerated accelerator memory (sum of device VRAM; the unified working set on SoCs) + #[prost(uint64, optional, tag = "1")] + pub total_bytes: ::core::option::Option, + /// Driver/runtime reserved or unavailable bytes, when the platform reports a true value + #[prost(uint64, optional, tag = "2")] + pub reserved_bytes: ::core::option::Option, + /// Withheld by the node owner: the effective safety margin plus any max_vram cap remainder + #[prost(uint64, optional, tag = "3")] + pub configured_reserve_bytes: ::core::option::Option, + /// What remains for mesh placement after both reserves + #[prost(uint64, optional, tag = "4")] + pub usable_bytes: ::core::option::Option, + /// Total system RAM, when the platform reports it + #[prost(uint64, optional, tag = "5")] + pub system_ram_bytes: ::core::option::Option, + /// Portion of the node's local fit budget backed by system RAM; never advertised as accelerator capacity + #[prost(uint64, optional, tag = "6")] + pub ram_offload_bytes: ::core::option::Option, + /// Withheld by platform policy on unified-memory hosts (for example the 10% of RAM the Tegra collector keeps back); zero for discrete GPUs + #[prost(uint64, optional, tag = "7")] + pub platform_reserve_bytes: ::core::option::Option, +} #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct SignedNodeOwnership { #[prost(uint32, tag = "1")] diff --git a/crates/mesh-llm-system/src/hardware/mod.rs b/crates/mesh-llm-system/src/hardware/mod.rs index f457d4a0cc..9b92504a14 100644 --- a/crates/mesh-llm-system/src/hardware/mod.rs +++ b/crates/mesh-llm-system/src/hardware/mod.rs @@ -138,6 +138,14 @@ pub struct HardwareSurvey { pub gpu_reserved: Vec>, /// Per-GPU facts in device-enumeration order. pub gpus: Vec, + /// Total system RAM in bytes when the platform reports it. Informational: + /// it feeds the itemized capacity announcement, never a budget. + pub system_ram_bytes: Option, + /// Portion of `vram_bytes` that system RAM backs rather than accelerator + /// memory: the RAM-offload credit on discrete-GPU hosts, the whole budget + /// on CPU-only hosts, zero on unified-memory hosts. Derived once in + /// `query` so every collector path reports it the same way. + pub ram_offload_bytes: u64, } #[derive(Clone, Copy, PartialEq, Eq, Hash)] @@ -208,6 +216,7 @@ fn apply_cpu_only_runtime_budget( } let system_ram = system_ram(); if system_ram > 0 { + survey.system_ram_bytes = Some(system_ram); survey.vram_bytes = (system_ram as f64 * 0.75) as u64; } } @@ -425,11 +434,13 @@ fn survey_system_ram() -> u64 { /// Applies a GPU probe outcome to the survey. The real probe (skippy device /// enumeration, /proc/meminfo, the Windows CIM query) stays in the callers so /// this decision can be exercised with injected values on every platform. -/// The system RAM closure feeds the CPU-only fallback branches and the -/// discrete-GPU RAM-offload credit; it runs only when VramBytes is requested, -/// and never for a unified-memory survey, so probes that skip VramBytes never -/// pay for it. Platform gating lives in the production RAM source -/// (`survey_system_ram`), which keeps this seam platform-pure. +/// The system RAM closure feeds the CPU-only fallback branches, the +/// informational `system_ram_bytes` of every GPU survey and the discrete-GPU +/// RAM-offload credit; it runs whenever VramBytes is requested and never +/// otherwise, so probes that skip VramBytes never pay for it. A +/// unified-memory survey records the reading but never credits it. Platform +/// gating lives in the production RAM source (`survey_system_ram`), which +/// keeps this seam platform-pure. #[cfg(any(feature = "skippy-devices", test))] fn apply_gpu_probe_outcome_to_survey( survey: &mut HardwareSurvey, @@ -469,11 +480,17 @@ fn apply_gpu_probe_outcome_to_survey( survey.gpu_vram = gpus.iter().map(|gpu| gpu.vram_bytes).collect(); survey.gpu_reserved = gpus.iter().map(|gpu| gpu.reserved_bytes).collect(); let vram: u64 = survey.gpu_vram.iter().sum(); + // The RAM reading is recorded on every host as an informational + // item; only the discrete branch turns it into an offload credit. + let system_ram = system_ram(); + if system_ram > 0 { + survey.system_ram_bytes = Some(system_ram); + } if unified_memory { let reserved: u64 = survey.gpu_reserved.iter().flatten().copied().sum(); survey.vram_bytes = vram.saturating_sub(reserved); } else { - let ram_offload = system_ram().saturating_sub(vram); + let ram_offload = system_ram.saturating_sub(vram); survey.vram_bytes = vram + (ram_offload as f64 * 0.90) as u64; } } @@ -525,6 +542,9 @@ impl Collector for DefaultCollector { ))] { let system_ram = read_system_ram_bytes(); + if system_ram > 0 { + survey.system_ram_bytes = Some(system_ram); + } if metrics.contains(&Metric::VramBytes) { // Try NVIDIA (mesh.rs:284-316) @@ -757,6 +777,9 @@ impl Collector for DefaultCollector { ))] { let system_ram = read_windows_total_ram_bytes().unwrap_or(0); + if system_ram > 0 { + survey.system_ram_bytes = Some(system_ram); + } let want_gpu_info = metrics.contains(&Metric::GpuName) || metrics.contains(&Metric::GpuCount); let want_vram = metrics.contains(&Metric::VramBytes); @@ -886,6 +909,7 @@ impl Collector for TegraCollector { })() .or_else(try_tegrastats_ram); if let Some(ram) = total_ram { + survey.system_ram_bytes = Some(ram); survey.vram_bytes = (ram as f64 * 0.90) as u64; survey.gpu_vram = vec![ram]; } @@ -1300,9 +1324,29 @@ pub fn query(metrics: &[Metric]) -> HardwareSurvey { if metrics.contains(&Metric::GpuFacts) && survey.gpus.is_empty() { hydrate_gpu_facts(&mut survey, metrics); } + survey.ram_offload_bytes = ram_offload_bytes(&survey); survey } +/// Portion of `vram_bytes` that system RAM backs: whatever the budget carries +/// beyond the enumerated accelerator memory. Unified-memory hosts budget from +/// their working set, so they never carry a RAM credit, and a budget that +/// trails the enumerated memory (reserved bytes subtracted) yields zero. +fn ram_offload_bytes(survey: &HardwareSurvey) -> u64 { + if survey.is_soc { + return 0; + } + // Same precedence as the host runtime's capacity accounting: the + // per-device facts first, the legacy per-GPU list only when there are + // none, so the two never disagree on which source wins. + let device_vram: u64 = if survey.gpus.is_empty() { + survey.gpu_vram.iter().sum() + } else { + survey.gpus.iter().map(|gpu| gpu.vram_bytes).sum() + }; + survey.vram_bytes.saturating_sub(device_vram) +} + pub fn survey() -> HardwareSurvey { query(&[ Metric::GpuName, diff --git a/crates/mesh-llm-system/src/hardware/tests.rs b/crates/mesh-llm-system/src/hardware/tests.rs index a8fa8a9d71..4814d01cd7 100644 --- a/crates/mesh-llm-system/src/hardware/tests.rs +++ b/crates/mesh-llm-system/src/hardware/tests.rs @@ -729,6 +729,7 @@ fn test_empty_gpu_probe_applies_cpu_only_budget_only_when_vram_requested() { ); assert!(handled); assert_eq!(survey.vram_bytes, 12_000_000_000); + assert_eq!(survey.system_ram_bytes, Some(16_000_000_000)); assert!(survey.gpu_vram.is_empty()); assert!(survey.gpus.is_empty()); @@ -741,6 +742,7 @@ fn test_empty_gpu_probe_applies_cpu_only_budget_only_when_vram_requested() { ); assert!(handled); assert_eq!(survey.vram_bytes, 0); + assert_eq!(survey.system_ram_bytes, None); } #[test] @@ -760,6 +762,7 @@ fn test_healthy_gpu_probe_credits_ram_offload_from_injected_source() { assert!(handled); assert_eq!(survey.vram_bytes, 30_000_000_000); assert_eq!(survey.gpu_vram, vec![12_000_000_000]); + assert_eq!(survey.system_ram_bytes, Some(32_000_000_000)); } #[test] @@ -777,6 +780,7 @@ fn test_healthy_gpu_probe_with_zero_ram_source_advertises_bare_vram() { ); assert!(handled); assert_eq!(survey.vram_bytes, 12_000_000_000); + assert_eq!(survey.system_ram_bytes, None); } #[test] @@ -795,7 +799,7 @@ fn test_healthy_gpu_probe_ram_below_vram_saturates_to_bare_vram() { } #[test] -fn test_healthy_soc_probe_does_not_probe_system_ram() { +fn test_healthy_soc_probe_records_system_ram_without_crediting_it() { let mut survey = HardwareSurvey::default(); let mut gpu = synthetic_gpu(0, None); gpu.vram_bytes = 16_000_000_000; @@ -805,9 +809,12 @@ fn test_healthy_soc_probe_does_not_probe_system_ram() { &mut survey, &[Metric::IsSoc, Metric::VramBytes], Ok::, ()>(vec![gpu]), - || panic!("system RAM must not be probed for a unified-memory survey"), + || 32_000_000_000, ); assert!(handled); + assert!(survey.is_soc); + // Informational only: the budget stays the working set minus the reserve. + assert_eq!(survey.system_ram_bytes, Some(32_000_000_000)); assert_eq!(survey.vram_bytes, 14_000_000_000); } @@ -822,10 +829,11 @@ fn test_healthy_soc_probe_without_is_soc_metric_still_skips_ram_offload() { &mut survey, &[Metric::VramBytes], Ok::, ()>(vec![gpu]), - || panic!("system RAM must not be probed for unified memory, even without IsSoc"), + || 32_000_000_000, ); assert!(handled); assert!(!survey.is_soc); + assert_eq!(survey.system_ram_bytes, Some(32_000_000_000)); assert_eq!(survey.vram_bytes, 14_000_000_000); } @@ -1181,3 +1189,74 @@ fn test_tegra_collector_sysfs_fixture() { Some("Jetson AGX Orin".to_string()) ); } + +#[test] +fn test_ram_offload_bytes_is_the_budget_beyond_device_vram_on_discrete_hosts() { + // 12 GB dGPU credited to 30 GB: the 18 GB beyond the device is RAM. + let survey = HardwareSurvey { + vram_bytes: 30_000_000_000, + gpu_vram: vec![12_000_000_000], + ..HardwareSurvey::default() + }; + assert_eq!(ram_offload_bytes(&survey), 18_000_000_000); +} + +#[test] +fn test_ram_offload_bytes_uses_gpu_facts_when_the_legacy_list_is_empty() { + let mut gpu = synthetic_gpu(0, None); + gpu.vram_bytes = 12_000_000_000; + let survey = HardwareSurvey { + vram_bytes: 30_000_000_000, + gpus: vec![gpu], + ..HardwareSurvey::default() + }; + assert_eq!(ram_offload_bytes(&survey), 18_000_000_000); +} + +#[test] +fn test_ram_offload_bytes_prefers_gpu_facts_over_the_legacy_list() { + // Both sources populated and disagreeing: the per-device facts win, the + // same precedence the host runtime applies when it itemizes capacity. + let mut gpu = synthetic_gpu(0, None); + gpu.vram_bytes = 12_000_000_000; + let survey = HardwareSurvey { + vram_bytes: 30_000_000_000, + gpu_vram: vec![16_000_000_000], + gpus: vec![gpu], + ..HardwareSurvey::default() + }; + assert_eq!(ram_offload_bytes(&survey), 18_000_000_000); +} + +#[test] +fn test_ram_offload_bytes_is_the_whole_budget_on_cpu_only_hosts() { + let survey = HardwareSurvey { + vram_bytes: 24_000_000_000, + ..HardwareSurvey::default() + }; + assert_eq!(ram_offload_bytes(&survey), 24_000_000_000); +} + +#[test] +fn test_ram_offload_bytes_is_zero_on_unified_memory_hosts() { + let survey = HardwareSurvey { + vram_bytes: 96_000_000_000, + is_soc: true, + gpu_vram: vec![128_000_000_000], + gpu_reserved: vec![Some(16_000_000_000)], + ..HardwareSurvey::default() + }; + assert_eq!(ram_offload_bytes(&survey), 0); +} + +#[test] +fn test_ram_offload_bytes_never_underflows_when_the_budget_trails_device_vram() { + // A unified-memory probe answered without the IsSoc metric leaves is_soc + // false while the budget already sits below the enumerated memory. + let survey = HardwareSurvey { + vram_bytes: 96_000_000_000, + gpu_vram: vec![128_000_000_000], + ..HardwareSurvey::default() + }; + assert_eq!(ram_offload_bytes(&survey), 0); +} diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 7743d5ea9f..59706d0f1b 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -436,7 +436,7 @@ fallbacks for non-Skippy builds and diagnostic surfaces, but they must not invent GPU count, backend identity, or usable runtime capacity when the embedded backend reports no selectable GPU. -`survey()` calls all applicable collectors and returns a `HardwareSurvey` with `gpu_name`, `gpu_vram` (per-GPU bytes), `gpu_reserved` (per-GPU reserved or unavailable bytes when the platform reports a true reserved/unavailable metric), `vram_bytes` (total), `hostname`, `is_soc`, and per-device `GpuFacts` entries. Benchmark-derived memory-bandwidth and compute-throughput hints are attached later when cached or freshly measured results are available. ROCm `rocm-smi --showmeminfo` and Intel `xpu-smi` discovery expose live used-memory counters, so mesh-llm intentionally omits `gpu_reserved` for those backends instead of reinterpreting used bytes as reserved memory. +`survey()` calls all applicable collectors and returns a `HardwareSurvey` with `gpu_name`, `gpu_vram` (per-GPU bytes), `gpu_reserved` (per-GPU reserved or unavailable bytes when the platform reports a true reserved/unavailable metric), `vram_bytes` (total), `hostname`, `is_soc`, per-device `GpuFacts` entries, `system_ram_bytes` (total system RAM when the platform reports it), and `ram_offload_bytes` (the share of `vram_bytes` backed by system RAM rather than accelerator memory, derived once in `query`). Benchmark-derived memory-bandwidth and compute-throughput hints are attached later when cached or freshly measured results are available. ROCm `rocm-smi --showmeminfo` and Intel `xpu-smi` discovery expose live used-memory counters, so mesh-llm intentionally omits `gpu_reserved` for those backends instead of reinterpreting used bytes as reserved memory. ### Gossip Fields @@ -449,6 +449,7 @@ backend reports no selectable GPU. | `is_soc` | `Option` | True for Tegra/Jetson (unified memory) | | `gpu_vram` | `Option` | Comma-separated per-GPU VRAM in bytes | | `gpu_reserved_bytes` | `Option` | Comma-separated per-GPU reserved bytes when the platform reports a true reserved/unavailable metric | +| `hardware.memory` | `optional MemoryInfo` | Itemized capacity behind `vram_bytes`: total, driver reserve, platform reserve, configured reserve, usable, plus system RAM and the RAM-backed share of the local budget as informational items | | `gpu_mem_bandwidth_gbps` | `Option` | Comma-separated per-GPU memory bandwidth measurements or cached benchmark results | | `gpu_compute_tflops_fp32` | `Option` | Comma-separated per-GPU FP32 compute-throughput hints | | `gpu_compute_tflops_fp16` | `Option` | Comma-separated per-GPU FP16 compute-throughput hints | @@ -461,7 +462,7 @@ GGUF-derived metadata (architecture, quantization type, tokenizer, RoPE paramete ### `--no-enumerate-host` Flag -By default, nodes broadcast their GPU name, hostname, VRAM capacity, and reserved bytes to all mesh peers. Pass `--no-enumerate-host` to suppress this hardware identification. `is_soc` is always sent. Benchmark-derived bandwidth and compute hints remain additive optional fields when available. `gpu_reserved_bytes` stays omitted on backends such as ROCm and Intel where the tooling does not report a true reserved/unavailable memory metric. +By default, nodes broadcast their GPU name, hostname, VRAM capacity, reserved bytes, and the itemized capacity block (`hardware.memory`) to all mesh peers. Pass `--no-enumerate-host` to suppress this hardware identification. `is_soc` and the `vram_bytes` budget are always sent. Benchmark-derived bandwidth and compute hints remain additive optional fields when available. `gpu_reserved_bytes` stays omitted on backends such as ROCm and Intel where the tooling does not report a true reserved/unavailable memory metric. ``` --no-enumerate-host # opt out: suppress GPU name and hostname from gossip diff --git a/docs/design/message_protocol.md b/docs/design/message_protocol.md index 8195b4fe05..f53c1630f1 100644 --- a/docs/design/message_protocol.md +++ b/docs/design/message_protocol.md @@ -256,7 +256,8 @@ Each `PeerAnnouncement` describes one node's state. Fields: | `gpu_mem_bandwidth_gbps` | Comma-separated per-GPU memory-bandwidth values in GB/s (gigabytes/sec) when known; the field name is retained for wire compatibility | | `gpu_compute_tflops_fp32` | Comma-separated per-GPU FP32 compute-throughput hints when known | | `gpu_compute_tflops_fp16` | Comma-separated per-GPU FP16 compute-throughput hints when known | -| `vram_bytes` | Total GPU VRAM in bytes | +| `vram_bytes` | Advertised accelerator capacity in bytes: the placement budget peers size against | +| `hardware.memory` | Itemized view of `vram_bytes` (`MemoryInfo`, tag 4 of `HardwareInfo`, additive): `total_bytes`, `reserved_bytes`, `platform_reserve_bytes`, `configured_reserve_bytes`, `usable_bytes`, plus the informational `system_ram_bytes` and `ram_offload_bytes`; sent only when host enumeration is enabled | | `model_source` | Source identifier for the model (e.g. HuggingFace repo) | | `primary_serving` | Primary model being served; backward-compat alias for `serving` | | `serving_models` | Models currently being served | @@ -274,6 +275,8 @@ Each `PeerAnnouncement` describes one node's state. Fields: These GPU telemetry fields are additive and optional. Older peers continue to interoperate by ignoring unknown `/1` protobuf fields, and the richer hardware reporting does not replace the existing model-metadata flow. For the shipped Skippy-enabled binary, GPU telemetry represents devices the embedded backend reports as runtime-selectable; platform probes are not a fallback source for advertised GPU count or usable capacity when Skippy reports no backend GPU. For clarity, `gpu_mem_bandwidth_gbps` values are serialized in GB/s (gigabytes/sec), matching benchmark output and CLI formatting; only the field name still carries the older `gbps` suffix for backward compatibility. ROCm `rocm-smi --showmeminfo` and Intel `xpu-smi` discovery expose used-memory counters rather than a true reserved/system-memory value, so `gpu_reserved_bytes` is intentionally omitted for those backends. +`hardware.memory` itemizes the capacity behind `vram_bytes`. `total_bytes` is the enumerated accelerator memory (the sum of device VRAM, or the unified working set on SoCs), `reserved_bytes` the driver/runtime reserve when the platform reports a true value, `platform_reserve_bytes` what the platform keeps back by policy on unified-memory hosts (the Tegra collector budgets 90% of physical RAM; zero for discrete GPUs, and zero on Metal while the survey reports the working set as the device memory), `configured_reserve_bytes` what the node owner withholds (the effective `defaults.hardware.safety_margin_gb`, plus whatever a `max_vram_gb` cap leaves out), and `usable_bytes` the remainder, so that `total_bytes = reserved_bytes + platform_reserve_bytes + configured_reserve_bytes + usable_bytes`. `system_ram_bytes` and `ram_offload_bytes` describe the node's local fit budget (its total system RAM, and the share of that budget backed by RAM) and are never counted as accelerator capacity. `vram_bytes` remains the placement budget: the block explains it rather than replacing it, and peers that predate it ignore the field. + ### Admission advertisement (tag 49) The optional `admission` field (protobuf tag 49 on `PeerAnnouncement`) carries diff --git a/docs/specs/vram-accounting.md b/docs/specs/vram-accounting.md index f7d58f4960..edaae66457 100644 --- a/docs/specs/vram-accounting.md +++ b/docs/specs/vram-accounting.md @@ -39,6 +39,7 @@ usable breakdown that will replace the single value). | `crates/mesh-llm-commands/src/gpus.rs` | `HardwareSurvey.gpus` | user-facing CLI and machine JSON | Human CLI displays rated VRAM; JSON keeps raw `vram_bytes` and adds rated/allocatable fields. | | `crates/mesh-llm/src/commands/models/formatters.rs` | `hardware::survey().vram_bytes` | mixed | Model search fit hints use reported capacity. Human summary still reports effective available capacity. | | `crates/mesh-llm-host-runtime/src/mesh/mod.rs` | `HardwareSurvey` startup snapshot | internal and protocol | Stores node `vram_bytes`, `gpu_vram`, and `gpu_reserved_bytes` for runtime, gossip, and status. | +| `crates/mesh-llm-host-runtime/src/mesh/capacity.rs` | `HardwareSurvey` startup snapshot and the effective safety margin | internal and protocol | Derives the advertised placement budget and its itemized breakdown (total, driver reserve, platform reserve, configured reserve, usable, system RAM, RAM-backed share) for gossip. | | `crates/mesh-llm-host-runtime/src/protocol/convert.rs` | peer announcements and protobuf GPU fields | protocol/internal | Preserves additive per-GPU totals and reserved bytes across mixed-version gossip. | | `crates/mesh-llm-host-runtime/src/api/status.rs` | node fields and GPU CSV fields | API for user-facing console | Emits raw `vram_bytes`, `reserved_bytes`, rated VRAM, and allocatable VRAM per GPU. | | `crates/mesh-llm-host-runtime/src/runtime/local.rs` | startup model specs and pinned GPU targets | internal | Skippy fit targets use allocatable capacity for pinned GPUs. | From 1ff184c08b563d959beb7cb344abcb39b46f1712 Mon Sep 17 00:00:00 2001 From: Steven Mih / Ahana / Linux Foundation Presto Date: Thu, 10 Sep 2026 11:01:40 -0700 Subject: [PATCH 25/41] feat(system): gpu_name_source beside gpu_name (#1679) * fix gpu_name_source doc invariant and make Tegra name test deterministic * read backend_device for Metal source, align MetalDefaultDevice docs * fold name+source assignments, fix serde shape, drop dead Deserialize, add Tegra present test --- crates/mesh-llm-system/src/hardware/mod.rs | 148 +++++++++++++++++-- crates/mesh-llm-system/src/hardware/tests.rs | 126 ++++++++++++++++ 2 files changed, 261 insertions(+), 13 deletions(-) diff --git a/crates/mesh-llm-system/src/hardware/mod.rs b/crates/mesh-llm-system/src/hardware/mod.rs index 9b92504a14..2c1e432b7f 100644 --- a/crates/mesh-llm-system/src/hardware/mod.rs +++ b/crates/mesh-llm-system/src/hardware/mod.rs @@ -13,7 +13,7 @@ mod tests; use parsers::macos_metal_gpu_budget; pub use parsers::*; -#[derive(Default, Debug, Clone, PartialEq)] +#[derive(Default, Debug, Clone, PartialEq, serde::Serialize)] pub struct GpuFacts { pub index: usize, pub display_name: String, @@ -120,12 +120,21 @@ impl std::fmt::Display for PinnedGpuResolverError { impl std::error::Error for PinnedGpuResolverError {} -#[derive(Default, Debug, Clone, PartialEq)] +#[derive(Default, Debug, Clone, PartialEq, serde::Serialize)] pub struct HardwareSurvey { pub vram_bytes: u64, /// GPU name as reported by the OS/driver (e.g. Metal, nvidia-smi, ROCm). /// Best-effort and OS-reported, not an independently verified measurement. + #[serde(skip_serializing_if = "Option::is_none")] pub gpu_name: Option, + /// Collection mechanism behind `gpu_name`. A source, not a verification — + /// it names which probe produced the string, not that the string is a + /// confirmed-accurate GPU identifier. `None` when no source is available + /// for `gpu_name`. Note this is not the same as `gpu_name` being `None`: + /// `hydrate_gpu_facts_with_identities` backfills placeholder `"GPU N"` + /// names with no naming probe behind them, tagged `GpuNameSource::Unknown`. + #[serde(skip_serializing_if = "Option::is_none")] + pub gpu_name_source: Option, pub gpu_count: u8, pub hostname: Option, pub is_soc: bool, @@ -148,6 +157,56 @@ pub struct HardwareSurvey { pub ram_offload_bytes: u64, } +/// Where a `HardwareSurvey.gpu_name` value came from. Each variant names a +/// collection mechanism, not a claim that the resulting string is a verified +/// GPU identifier. +/// +/// `HardwareSurvey` and `GpuFacts` are serialize-only (`serde::Serialize`). +/// This enum therefore derives only `Serialize` — a `Deserialize` impl would +/// be unreachable through any serializable struct and is omitted to avoid a +/// dead, untestable code path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GpuNameSource { + /// A Metal device name from `MTLDevice.name` (macOS). Assigned when the + /// native-runtime backend reports a device whose backend name starts with + /// `"MTL"`, or when `MTLCreateSystemDefaultDevice` is queried directly + /// via the DefaultCollector macOS path. On switchable-graphics Macs the + /// default device is a moment-in-time fact: it can differ between + /// collections as the OS switches GPUs. Best-effort, OS-reported, not a + /// verified GPU identifier. + MetalDefaultDevice, + /// macOS `sysctl -n machdep.cpu.brand_string`, used before upstream + /// commit 6e16b84a2 (`fix(system): report the real macOS GPU name`). + /// **Not assigned by new collections.** Kept in the vocabulary so that + /// consumers reading surveys recorded before 6e16b84a2 can deserialise + /// the field; it will never appear in a freshly collected survey. + /// (`HardwareSurvey` has no `Deserialize` impl today, so this variant + /// is currently write-only; it is preserved for when a `Deserialize` impl + /// is added rather than forcing a breaking vocabulary change at that point.) + CpuBrandString, + /// The skippy native-runtime's backend device enumeration reporting a + /// non-Metal accelerator (CUDA, ROCm, Vulkan, SYCL, ...). Names whichever + /// accelerator the loaded runtime enumerated; does not by itself say + /// which of those backends answered. + NativeRuntimeDevice, + /// `nvidia-smi --query-gpu=name` output. + NvidiaSmi, + /// `rocm-smi --showproductname` output. + RocmSmi, + /// `xpu-smi discovery` JSON output (Intel GPUs). + XpuSmi, + /// Windows `Win32_VideoController` CIM/WMI query (`Name` field). + WindowsVideoController, + /// A device-tree model string read from sysfs + /// (`/sys/firmware/devicetree/base/model`), used on Tegra/Jetson boards. + Sysfs, + /// A name is present but was not produced by any naming probe above — + /// e.g. a placeholder ("GPU 0") backfilled from GPU count/VRAM data + /// alone. Never treat this as identifying real hardware. + Unknown, +} + #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum Metric { GpuName, @@ -343,6 +402,10 @@ fn query_metal_recommended_working_set_bytes() -> Option { /// "Apple M4 Max" or "AMD Radeon Pro 5500M") — best-effort, not a verified /// measurement, but sourced from the GPU device rather than the CPU. #[cfg(target_os = "macos")] +#[cfg_attr( + all(feature = "skippy-devices", not(feature = "dynamic-native-runtime")), + allow(dead_code) +)] fn query_metal_device_name() -> Option { use std::ffi::{CStr, c_char, c_void}; @@ -464,7 +527,24 @@ fn apply_gpu_probe_outcome_to_survey( if metrics.contains(&Metric::GpuName) { let names: Vec = gpus.iter().map(|gpu| gpu.display_name.clone()).collect(); - survey.gpu_name = summarize_gpu_name(&names); + // Derive the source from the actual backend device names the + // runtime reported, not from the target OS: a macOS host running + // MoltenVK/Vulkan stamps non-MTL device names and must not be + // labelled MetalDefaultDevice. + let is_metal = gpus.iter().any(|gpu| { + gpu.backend_device + .as_deref() + .map(|d| d.starts_with("MTL")) + .unwrap_or(false) + }); + let name = summarize_gpu_name(&names); + let source = if is_metal { + GpuNameSource::MetalDefaultDevice + } else { + GpuNameSource::NativeRuntimeDevice + }; + survey.gpu_name_source = name.is_some().then_some(source); + survey.gpu_name = name; } if metrics.contains(&Metric::GpuCount) { survey.gpu_count = u8::try_from(gpus.len()).unwrap_or(u8::MAX); @@ -529,7 +609,10 @@ impl Collector for DefaultCollector { survey.gpu_reserved = vec![reserved_bytes]; } if metrics.contains(&Metric::GpuName) { - survey.gpu_name = sanitize_macos_gpu_name(query_metal_device_name()); + let name = sanitize_macos_gpu_name(query_metal_device_name()); + survey.gpu_name_source = + name.is_some().then_some(GpuNameSource::MetalDefaultDevice); + survey.gpu_name = name; } if metrics.contains(&Metric::GpuCount) { survey.gpu_count = 1; @@ -714,7 +797,9 @@ impl Collector for DefaultCollector { if let Some(ref names) = nvidia_names { if metrics.contains(&Metric::GpuName) { - survey.gpu_name = summarize_gpu_name(names); + let name = summarize_gpu_name(names); + survey.gpu_name_source = name.is_some().then_some(GpuNameSource::NvidiaSmi); + survey.gpu_name = name; } if metrics.contains(&Metric::GpuCount) { survey.gpu_count = u8::try_from(names.len()).unwrap_or(u8::MAX); @@ -729,7 +814,10 @@ impl Collector for DefaultCollector { if let Ok(s) = String::from_utf8(out.stdout) { let names = parse_rocm_gpu_names(&s); if metrics.contains(&Metric::GpuName) { - survey.gpu_name = summarize_gpu_name(&names); + let name = summarize_gpu_name(&names); + survey.gpu_name_source = + name.is_some().then_some(GpuNameSource::RocmSmi); + survey.gpu_name = name; } if metrics.contains(&Metric::GpuCount) { survey.gpu_count = u8::try_from(names.len()).unwrap_or(u8::MAX); @@ -754,7 +842,10 @@ impl Collector for DefaultCollector { let names: Vec = gpus.iter().map(|gpu| gpu.name.clone()).collect(); if metrics.contains(&Metric::GpuName) { - survey.gpu_name = summarize_gpu_name(&names); + let name = summarize_gpu_name(&names); + survey.gpu_name_source = + name.is_some().then_some(GpuNameSource::XpuSmi); + survey.gpu_name = name; } if metrics.contains(&Metric::GpuCount) { survey.gpu_count = @@ -852,7 +943,9 @@ impl Collector for DefaultCollector { if want_gpu_info { if let Some(ref names) = nvidia_names { if metrics.contains(&Metric::GpuName) { - survey.gpu_name = summarize_gpu_name(names); + let name = summarize_gpu_name(names); + survey.gpu_name_source = name.is_some().then_some(GpuNameSource::NvidiaSmi); + survey.gpu_name = name; } if metrics.contains(&Metric::GpuCount) { survey.gpu_count = u8::try_from(names.len()).unwrap_or(u8::MAX); @@ -861,7 +954,11 @@ impl Collector for DefaultCollector { let names: Vec = windows_gpus.iter().map(|(name, _)| name.clone()).collect(); if metrics.contains(&Metric::GpuName) { - survey.gpu_name = summarize_gpu_name(&names); + let name = summarize_gpu_name(&names); + survey.gpu_name_source = name + .is_some() + .then_some(GpuNameSource::WindowsVideoController); + survey.gpu_name = name; } if metrics.contains(&Metric::GpuCount) { survey.gpu_count = u8::try_from(names.len()).unwrap_or(u8::MAX); @@ -874,6 +971,28 @@ impl Collector for DefaultCollector { } } +/// Read the Tegra/Jetson model name from `model_path` and record it (with its +/// `Sysfs` source) on `survey`. Leaves both `gpu_name` and `gpu_name_source` +/// untouched when the path is absent or unparseable — never a guessed source +/// for a name that was not actually read. Split out from `collect` so the path +/// can be driven deterministically in tests rather than depending on host +/// filesystem state. +#[cfg(all( + target_os = "linux", + any( + not(feature = "skippy-devices"), + feature = "dynamic-native-runtime", + test + ) +))] +fn tegra_gpu_name_from_model_path(survey: &mut HardwareSurvey, model_path: &std::path::Path) { + let name = std::fs::read_to_string(model_path) + .ok() + .and_then(|model| parse_tegra_model_name(&model)); + survey.gpu_name_source = name.is_some().then_some(GpuNameSource::Sysfs); + survey.gpu_name = name; +} + #[cfg(all( target_os = "linux", any( @@ -891,9 +1010,10 @@ impl Collector for TegraCollector { } if metrics.contains(&Metric::GpuName) { - survey.gpu_name = std::fs::read_to_string("/sys/firmware/devicetree/base/model") - .ok() - .and_then(|model| parse_tegra_model_name(&model)); + tegra_gpu_name_from_model_path( + &mut survey, + std::path::Path::new("/sys/firmware/devicetree/base/model"), + ); } if metrics.contains(&Metric::VramBytes) { @@ -1309,7 +1429,9 @@ fn hydrate_gpu_facts_with_identities( .iter() .map(|gpu| gpu.display_name.clone()) .collect(); - survey.gpu_name = summarize_gpu_name(&names); + let name = summarize_gpu_name(&names); + survey.gpu_name_source = name.is_some().then_some(GpuNameSource::Unknown); + survey.gpu_name = name; } } diff --git a/crates/mesh-llm-system/src/hardware/tests.rs b/crates/mesh-llm-system/src/hardware/tests.rs index 4814d01cd7..4cd862c13d 100644 --- a/crates/mesh-llm-system/src/hardware/tests.rs +++ b/crates/mesh-llm-system/src/hardware/tests.rs @@ -378,6 +378,81 @@ fn test_hydrate_gpu_facts_uses_uuid_and_cuda_for_tegra_soc() { assert!(survey.gpus[0].unified_memory); } +// Snapshot: when no collector set gpu_name, the placeholder "GPU {index}" +// join that backfills it is unchanged by this PR; gpu_name_source labels it +// Unknown so nothing downstream mistakes a placeholder for real hardware. +#[test] +fn test_hydrate_gpu_facts_backfill_tags_unknown_gpu_name_source() { + let mut survey = HardwareSurvey { + gpu_count: 2, + gpu_vram: vec![8_000_000_000, 8_000_000_000], + ..Default::default() + }; + + hydrate_gpu_facts(&mut survey, &[Metric::GpuFacts, Metric::GpuName]); + + assert_eq!(survey.gpu_name.as_deref(), Some("GPU 0, GPU 1")); + assert_eq!(survey.gpu_name_source, Some(GpuNameSource::Unknown)); +} + +// Mirrors the cfg on `tegra_gpu_name_from_model_path`: the Tegra collector only +// compiles for Linux non-skippy / dynamic-native-runtime builds. The tests now +// drive the model path directly, so they no longer depend on host filesystem +// state (the deterministic fix), only on the collector being present at all. +#[cfg(all( + target_os = "linux", + any( + not(feature = "skippy-devices"), + feature = "dynamic-native-runtime", + test + ) +))] +#[test] +fn test_tegra_collector_gpu_name_absent_leaves_source_none() { + // Drive the model read with a path guaranteed not to exist, so the result + // is independent of host filesystem state (a real Tegra host has the sysfs + // model file present, which made the old `TegraCollector.collect` form flip + // on such a host). With the model file absent, both the name and its source + // must stay absent — never a guessed source for a name that was never read. + let missing = std::path::Path::new("/nonexistent/mesh-llm/tegra/devicetree/base/model"); + assert!(!missing.exists()); + + let mut survey = HardwareSurvey::default(); + tegra_gpu_name_from_model_path(&mut survey, missing); + + assert_eq!(survey.gpu_name, None); + assert_eq!(survey.gpu_name_source, None); +} + +// Sysfs-PRESENT case: the helper reads a real temp file containing a Tegra +// model string, parses it, and tags both the name and source. Exercisable on +// any platform because the helper is path-injectable. +#[cfg(all( + target_os = "linux", + any( + not(feature = "skippy-devices"), + feature = "dynamic-native-runtime", + test + ) +))] +#[test] +fn test_tegra_collector_gpu_name_present_tags_sysfs_source() { + use std::io::Write as _; + + let path = std::env::temp_dir().join("mesh_llm_test_tegra_model_present"); + let mut f = std::fs::File::create(&path).expect("create temp model file"); + write!(f, "NVIDIA Jetson AGX Orin Developer Kit\0").expect("write model file"); + drop(f); + + let mut survey = HardwareSurvey::default(); + tegra_gpu_name_from_model_path(&mut survey, &path); + + let _ = std::fs::remove_file(&path); + + assert_eq!(survey.gpu_name.as_deref(), Some("Jetson AGX Orin")); + assert_eq!(survey.gpu_name_source, Some(GpuNameSource::Sysfs)); +} + #[test] fn test_summarize_gpu_name_single() { assert_eq!( @@ -660,6 +735,7 @@ fn test_hardware_survey_default() { let s = HardwareSurvey::default(); assert_eq!(s.vram_bytes, 0); assert_eq!(s.gpu_name, None); + assert_eq!(s.gpu_name_source, None); assert_eq!(s.gpu_count, 0); assert_eq!(s.hostname, None); assert!(s.gpu_vram.is_empty()); @@ -850,6 +926,54 @@ fn test_healthy_gpu_probe_without_vram_metric_does_not_probe_system_ram() { assert_eq!(survey.vram_bytes, 0); } +// Snapshot: this probe's gpu_name value must stay exactly what it was before +// gpu_name_source existed (the display_name join over the probed GPUs). +// gpu_name_source is new; gpu_name is not. +#[test] +fn test_skippy_probe_gpu_name_unchanged_and_source_tagged() { + // synthetic_gpu uses "CUDA0" as backend_device — NativeRuntimeDevice on + // every platform, regardless of target OS. + let mut survey = HardwareSurvey::default(); + let handled = apply_gpu_probe_outcome_to_survey( + &mut survey, + &[Metric::GpuName], + Ok::, ()>(vec![synthetic_gpu(0, None)]), + || 0, + ); + assert!(handled); + assert_eq!(survey.gpu_name.as_deref(), Some("GPU 0")); + assert_eq!( + survey.gpu_name_source, + Some(GpuNameSource::NativeRuntimeDevice) + ); +} + +#[test] +fn test_skippy_probe_metal_backend_device_tagged_metal_default_device() { + // A device whose backend_device name starts with "MTL" (as the skippy + // Metal backend uses) must be labelled MetalDefaultDevice regardless of + // the host OS, so a macOS host running MoltenVK (Vulkan backend) is not + // mislabelled. + let mut survey = HardwareSurvey::default(); + let metal_gpu = GpuFacts { + backend_device: Some("MTL0".to_string()), + display_name: "Apple M4 Max".to_string(), + ..synthetic_gpu(0, None) + }; + let handled = apply_gpu_probe_outcome_to_survey( + &mut survey, + &[Metric::GpuName], + Ok::, ()>(vec![metal_gpu]), + || 0, + ); + assert!(handled); + assert_eq!(survey.gpu_name.as_deref(), Some("Apple M4 Max")); + assert_eq!( + survey.gpu_name_source, + Some(GpuNameSource::MetalDefaultDevice) + ); +} + #[cfg(target_os = "macos")] #[test] fn test_probe_fallback_leaves_vram_untouched_on_macos() { @@ -890,6 +1014,7 @@ fn test_skippy_backend_error_uses_cpu_only_budget_without_legacy_fallback() { assert!(handled); assert_eq!(survey.gpu_name, None); + assert_eq!(survey.gpu_name_source, None); assert_eq!(survey.gpu_count, 0); assert!(survey.gpu_vram.is_empty()); assert!(survey.gpus.is_empty()); @@ -917,6 +1042,7 @@ fn test_skippy_backend_empty_result_uses_cpu_only_budget_without_legacy_fallback assert!(handled); assert_eq!(survey.gpu_name, None); + assert_eq!(survey.gpu_name_source, None); assert_eq!(survey.gpu_count, 0); assert!(survey.gpu_vram.is_empty()); assert!(survey.gpus.is_empty()); From e540e7132d3be5b9a4d0e295f0eb6fc5b1e7b64f Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:29:37 -0400 Subject: [PATCH 26/41] fix: serialize materialized cache writes on Windows --- .../src/package/materialized_cache.rs | 76 +++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/skippy-runtime/src/package/materialized_cache.rs b/crates/skippy-runtime/src/package/materialized_cache.rs index ba0819cabe..d98fedb4f0 100644 --- a/crates/skippy-runtime/src/package/materialized_cache.rs +++ b/crates/skippy-runtime/src/package/materialized_cache.rs @@ -4,9 +4,6 @@ use std::{ sync::atomic::{AtomicU64, Ordering}, }; -#[cfg(unix)] -use std::os::fd::AsRawFd; - use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -96,10 +93,7 @@ pub(super) struct MaterializedOutputLock { impl Drop for MaterializedOutputLock { fn drop(&mut self) { - #[cfg(unix)] - unsafe { - let _ = libc::flock(self.file.as_raw_fd(), libc::LOCK_UN); - } + let _ = self.file.unlock(); } } @@ -117,13 +111,8 @@ pub(super) fn lock_output(output: &Path) -> Result { .open(&lock_path) .with_context(|| format!("open materialized cache lock {}", lock_path.display()))?; - #[cfg(unix)] - unsafe { - if libc::flock(file.as_raw_fd(), libc::LOCK_EX) != 0 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("lock materialized cache {}", lock_path.display())); - } - } + file.lock() + .with_context(|| format!("lock materialized cache {}", lock_path.display()))?; Ok(MaterializedOutputLock { file }) } @@ -281,3 +270,62 @@ fn sanitize_suffix(value: &str) -> String { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[test] + fn output_lock_excludes_other_processes_until_dropped() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("model.gguf"); + let guard = lock_output(&output).unwrap(); + assert_child_lock_state(&output, "locked"); + drop(guard); + assert_child_lock_state(&output, "unlocked"); + let guard = lock_output(&output).unwrap(); + assert_child_lock_state(&output, "locked"); + drop(guard); + } + + fn assert_child_lock_state(output: &Path, expected: &str) { + let result = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "package::materialized_cache::tests::probe_output_lock_in_child", + "--ignored", + "--nocapture", + ]) + .env("SKIPPY_TEST_OUTPUT_LOCK_PATH", output) + .env("SKIPPY_TEST_OUTPUT_LOCK_STATE", expected) + .output() + .unwrap(); + assert!( + result.status.success() + && String::from_utf8_lossy(&result.stdout).contains("1 passed;"), + "lock probe failed: {}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + } + + #[test] + #[ignore = "subprocess helper for output_lock_excludes_other_processes_until_dropped"] + fn probe_output_lock_in_child() { + let output = PathBuf::from(std::env::var_os("SKIPPY_TEST_OUTPUT_LOCK_PATH").unwrap()); + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .open(sibling_path(&output, "lock")) + .unwrap(); + match std::env::var("SKIPPY_TEST_OUTPUT_LOCK_STATE") + .unwrap() + .as_str() + { + "locked" => assert!(matches!(file.try_lock(), Err(fs::TryLockError::WouldBlock))), + "unlocked" => file.try_lock().unwrap(), + state => panic!("unexpected lock state: {state}"), + } + } +} From dc3ecf66da612984629656ad697e52de35b6a9a4 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:46:21 -0400 Subject: [PATCH 27/41] fix: use served model IDs in ready commands --- .../src/runtime/run_auto.rs | 1 - .../src/runtime/serving_surface.rs | 65 ++++++++++++++++--- .../src/runtime/startup_handles.rs | 39 +++++------ .../runtime/startup_handles/startup_loop.rs | 2 +- 4 files changed, 74 insertions(+), 33 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs b/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs index 16431c58ce..582bc8db1b 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/run_auto.rs @@ -1627,7 +1627,6 @@ pub(super) async fn run_auto(ctx: RunAutoContext) -> Result<()> { let primary_model_name = requested_model_names.first().cloned().unwrap_or_default(); let startup_ready_reporter = StartupReadyReporter::new_with_failure_policy( &requested_model_names, - primary_model_name.clone(), api_ready_url, ready_console_url, ready_api_port, diff --git a/crates/mesh-llm-host-runtime/src/runtime/serving_surface.rs b/crates/mesh-llm-host-runtime/src/runtime/serving_surface.rs index f2683fab9e..77849f9811 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/serving_surface.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/serving_surface.rs @@ -368,7 +368,6 @@ pub(crate) fn assert_passive_path_immediate_spawn_behavior() { pub(crate) fn assert_quitting_during_startup_cancels_without_late_ready_render() { let reporter = StartupReadyReporter::new( &["Qwen3-8B-Q4_K_M".to_string()], - "Qwen3-8B-Q4_K_M".to_string(), "http://127.0.0.1:9337".to_string(), Some("http://127.0.0.1:3131".to_string()), 9337, @@ -376,7 +375,9 @@ pub(crate) fn assert_quitting_during_startup_cancels_without_late_ready_render() ); reporter.mark_shutdown_requested(); assert!( - reporter.mark_ready_and_build_event(0).is_none(), + reporter + .mark_ready_and_build_event(0, "Qwen3-8B-Q4_K_M") + .is_none(), "startup shutdown should cancel any late RuntimeReady emission" ); } @@ -386,7 +387,6 @@ pub(crate) fn assert_startup_ready_reporter_waits_for_rust_owned_model_ready_edg let models = vec!["model-a".to_string(), "model-b".to_string()]; let reporter = StartupReadyReporter::new( &models, - "model-a".to_string(), "http://127.0.0.1:9337".to_string(), Some("http://127.0.0.1:3131".to_string()), 9337, @@ -394,16 +394,16 @@ pub(crate) fn assert_startup_ready_reporter_waits_for_rust_owned_model_ready_edg ); assert!( - reporter.mark_ready_and_build_event(0).is_none(), + reporter.mark_ready_and_build_event(0, "model-a").is_none(), "one model-ready edge must not replace the remaining Rust-owned readiness edges" ); assert!( - reporter.mark_ready_and_build_event(0).is_none(), + reporter.mark_ready_and_build_event(0, "model-a").is_none(), "a repeated edge for one startup slot must not mark a different slot ready" ); assert!( matches!( - reporter.mark_ready_and_build_event(1), + reporter.mark_ready_and_build_event(1, "model-b"), Some(OutputEvent::RuntimeReady { .. }) ), "RuntimeReady should appear only after every startup model hits the Rust-owned ready path" @@ -655,7 +655,6 @@ pub(super) async fn startup_ready_reporter_uses_bound_urls_for_runtime_ready() { let models = vec!["model-a".to_string()]; let reporter = StartupReadyReporter::new( &models, - "model-a".to_string(), api_url.clone(), Some(console_url.clone()), api_port, @@ -668,7 +667,7 @@ pub(super) async fn startup_ready_reporter_uses_bound_urls_for_runtime_ready() { api_port: reported_api_port, console_port: reported_console_port, .. - }) = reporter.mark_ready_and_build_event(0) + }) = reporter.mark_ready_and_build_event(0, "model-a") else { panic!("reporter should emit RuntimeReady when the model is ready"); }; @@ -686,6 +685,56 @@ pub(super) fn startup_ready_reporter_waits_for_rust_owned_model_ready_edges() { assert_startup_ready_reporter_waits_for_rust_owned_model_ready_edges(); } +#[test] +fn startup_ready_commands_use_the_primary_served_model_in_either_load_order() { + let primary_id = "local-gguf/sha256-f90897d3a4d7e185"; + for primary_first in [true, false] { + let models = vec![ + "/models/primary.gguf".to_string(), + "other-model".to_string(), + ]; + let reporter = StartupReadyReporter::new( + &models, + "http://127.0.0.1:9337".to_string(), + None, + 9337, + None, + ); + let (first, last) = if primary_first { (0, 1) } else { (1, 0) }; + let served_models = [primary_id, "other-served-model"]; + assert!( + reporter + .mark_ready_and_build_event(first, served_models[first]) + .is_none() + ); + let Some(OutputEvent::RuntimeReady { + pi_command, + goose_command, + .. + }) = reporter + .clone() + .mark_ready_and_build_event(last, served_models[last]) + else { + panic!("both loaded models should make the runtime ready"); + }; + assert_eq!( + pi_command.unwrap(), + format!("mesh-llm pi --host 127.0.0.1:9337 --model '{primary_id}'") + ); + assert_eq!( + goose_command.unwrap(), + format!( + "GOOSE_PROVIDER=openai OPENAI_HOST=http://127.0.0.1:9337 OPENAI_API_KEY=mesh GOOSE_MODEL={primary_id} goose session" + ) + ); + assert!( + reporter + .mark_ready_and_build_event(last, served_models[last]) + .is_none() + ); + } +} + #[cfg(test)] #[test] pub(super) fn dashboard_lanes_prefer_sparse_slot_ids() { diff --git a/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs b/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs index f1d41474e1..4e1d646316 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/startup_handles.rs @@ -762,12 +762,11 @@ pub(super) fn update_startup_target( #[derive(Clone)] pub(super) struct StartupReadyReporter { - pub(super) ready_by_model: Arc>>, + pub(super) ready_by_model: Arc>>>, pub(super) emitted: Arc, pub(super) shutdown_requested: Arc, startup_failure_policy: mesh_llm_config::StartupFailurePolicy, terminal_failures: Arc>>, - pub(super) primary_model: String, pub(super) api_url: String, pub(super) console_url: Option, pub(super) api_port: u16, @@ -784,7 +783,6 @@ impl StartupReadyReporter { )] pub(super) fn new( models: &[String], - primary_model: String, api_url: String, console_url: Option, api_port: u16, @@ -792,7 +790,6 @@ impl StartupReadyReporter { ) -> Self { Self::new_with_failure_policy( models, - primary_model, api_url, console_url, api_port, @@ -803,21 +800,19 @@ impl StartupReadyReporter { pub(super) fn new_with_failure_policy( models: &[String], - primary_model: String, api_url: String, console_url: Option, api_port: u16, console_port: Option, startup_failure_policy: mesh_llm_config::StartupFailurePolicy, ) -> Self { - let ready_by_model = vec![false; models.len()]; + let ready_by_model = vec![None; models.len()]; Self { ready_by_model: Arc::new(Mutex::new(ready_by_model)), emitted: Arc::new(AtomicBool::new(false)), shutdown_requested: Arc::new(AtomicBool::new(false)), startup_failure_policy, terminal_failures: Arc::new(Mutex::new(Vec::new())), - primary_model, api_url, console_url, api_port, @@ -877,24 +872,23 @@ impl StartupReadyReporter { self.shutdown_requested.store(true, Ordering::SeqCst); } - pub(super) fn mark_ready_and_build_event(&self, readiness_index: usize) -> Option { - let models_count = { + pub(super) fn mark_ready_and_build_event( + &self, + readiness_index: usize, + loaded_model: &str, + ) -> Option { + let (models_count, primary_model) = { let mut ready_by_model = self .ready_by_model .lock() .expect("startup readiness mutex poisoned"); - if let Some(entry) = ready_by_model.get_mut(readiness_index) { - *entry = true; - } - if ready_by_model.iter().all(|ready| *ready) { - Some(ready_by_model.len()) - } else { - None + *ready_by_model.get_mut(readiness_index)? = Some(loaded_model.to_string()); + if ready_by_model.iter().any(Option::is_none) { + return None; } + (ready_by_model.len(), ready_by_model[0].as_ref()?.clone()) }; - let models_count = models_count?; - if self.shutdown_requested.load(Ordering::SeqCst) { return None; }; @@ -906,11 +900,11 @@ impl StartupReadyReporter { let pi_command = Some(format!( "mesh-llm pi --host 127.0.0.1:{} --model {}", self.api_port, - single_quote_shell_arg(&self.primary_model) + single_quote_shell_arg(&primary_model) )); let goose_command = Some(format!( "GOOSE_PROVIDER=openai OPENAI_HOST={} OPENAI_API_KEY=mesh GOOSE_MODEL={} goose session", - self.api_url, self.primary_model + self.api_url, primary_model )); Some(OutputEvent::RuntimeReady { api_url: self.api_url.clone(), @@ -923,8 +917,8 @@ impl StartupReadyReporter { }) } - fn mark_ready_and_maybe_emit(&self, readiness_index: usize) { - let Some(event) = self.mark_ready_and_build_event(readiness_index) else { + fn mark_ready_and_maybe_emit(&self, readiness_index: usize, loaded_model: &str) { + let Some(event) = self.mark_ready_and_build_event(readiness_index, loaded_model) else { return; }; let _ = emit_event(event); @@ -948,7 +942,6 @@ mod startup_failure_policy_tests { fn reporter(policy: StartupFailurePolicy) -> StartupReadyReporter { StartupReadyReporter::new_with_failure_policy( &["model-a".to_string()], - "model-a".to_string(), "http://127.0.0.1:1".to_string(), None, 1, diff --git a/crates/mesh-llm-host-runtime/src/runtime/startup_handles/startup_loop.rs b/crates/mesh-llm-host-runtime/src/runtime/startup_handles/startup_loop.rs index f60699fb80..b384265926 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/startup_handles/startup_loop.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/startup_handles/startup_loop.rs @@ -918,7 +918,7 @@ pub(in crate::runtime) async fn startup_publish_loaded_runtime( cs.update(true, true).await; } update_pi_models_json(loaded_name, ctx.api_port); - startup_ready_reporter.mark_ready_and_maybe_emit(ctx.readiness_index); + startup_ready_reporter.mark_ready_and_maybe_emit(ctx.readiness_index, loaded_name); let _ = emit_event(OutputEvent::ModelReady { model: loaded_name.to_string(), internal_port: Some(handle.port), From 6c097d606fd475010d554c86b2f578d3795807a4 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Wed, 9 Sep 2026 19:16:56 -0400 Subject: [PATCH 28/41] test(ci): guard product smoke selector drift --- scripts/tests/test_ci_lane_workflows.py | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/scripts/tests/test_ci_lane_workflows.py b/scripts/tests/test_ci_lane_workflows.py index 05ee712269..18b3ad4fe1 100644 --- a/scripts/tests/test_ci_lane_workflows.py +++ b/scripts/tests/test_ci_lane_workflows.py @@ -339,6 +339,35 @@ def test_product_smoke_jobs_parse_formatted_matrix_json(self) -> None: ) self.assertNotIn("contains(inputs.smoke_matrix,", workflow) + def test_every_planned_smoke_id_matches_a_product_smoke_job(self) -> None: + """Prevent planner rows from silently skipping their smoke jobs. + + The product smoke workflows gate each job on an id from the formatted + ``smoke_matrix``. A stale id in ``smoke_domain_rows`` can therefore + leave the selected smoke coverage absent while the lane summary only + reports a skipped planned job. + """ + slices = json.loads((ROOT / "ci" / "slices.yml").read_text()) + declared = {row["id"] for row in slices["smoke_rows"]} + planned = { + smoke_id + for ids in slices["smoke_domain_rows"].values() + for smoke_id in ids + } + + self.assertEqual(set(), planned - declared) + + gated = set() + for path in sorted(WORKFLOWS.glob("ci-*-product-smoke-slice.yml")): + gated.update( + re.findall( + r"contains\(fromJson\(inputs\.smoke_matrix\)\.\*\.id, '([^']+)'\)", + path.read_text(encoding="utf-8"), + ) + ) + + self.assertEqual(set(), planned - gated) + def test_runtime_and_product_artifact_ids_preserve_architecture(self) -> None: for platform in ("linux", "macos", "windows"): with self.subTest(platform=platform): From 05201501027ca46d9cf68cc5da6677d9f3967f82 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Wed, 9 Sep 2026 19:21:07 -0400 Subject: [PATCH 29/41] ci: preflight packaging dispatch credentials --- .../manage-ci/references/current-inventory.md | 2 +- .github/workflows/release.yml | 11 ++++++++- RELEASE.md | 12 ++++++++-- ci/ci.md | 7 ++++++ .../tests/test_release_workflow_artifacts.py | 24 +++++++++++++++++++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.agents/skills/manage-ci/references/current-inventory.md b/.agents/skills/manage-ci/references/current-inventory.md index ceabb14d42..61709353f9 100644 --- a/.agents/skills/manage-ci/references/current-inventory.md +++ b/.agents/skills/manage-ci/references/current-inventory.md @@ -21,7 +21,7 @@ Read it with `../SKILL.md` and `ci/ci.md` before editing CI. | `main_windows.yml` (`Main · Windows`) | push to `main` | Exhaustive main planning plus the same-commit reusable Windows lane | | `ci.yml` | `workflow_call` only | Temporary inert shim for the former main ingress filename; pending protected-main runner-contract update; no push trigger or dispatch | | `ci-control.yml` (`CI · Manual Full`) | dispatch on default branch | Explicit operator-only full plan, bounded lane dispatch and correlated diagnostic checks | -| `release.yml` | release tags, dispatch | Canonical version synchronization, release-only signing, assets and publication | +| `release.yml` | release tags, dispatch | Canonical version synchronization, release-only signing, assets, publication, and a preflighted downstream `mesh-packaging` dispatch | | `website-pages.yml` | main website paths, dispatch | Public website deployment | | `pr_cleanup.yml` | PR close, dispatch | Positively matched cleanup only | | `pr_auto_assign.yml` | PR lifecycle | Metadata only | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef980e80d8..533e080f3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1862,6 +1862,7 @@ jobs: - name: Dispatch verified release to mesh-packaging env: GH_TOKEN: ${{ secrets.MESH_AGENT_IMAGES_DISPATCH_TOKEN }} + TARGET_REPOSITORY: Mesh-LLM/mesh-packaging RELEASE_TAG: ${{ needs.metadata.outputs.tag }} RELEASE_VERSION: ${{ needs.metadata.outputs.version }} run: | @@ -1870,6 +1871,14 @@ jobs: echo "MESH_AGENT_IMAGES_DISPATCH_TOKEN must grant Contents write access to Mesh-LLM/mesh-packaging for cross-repository dispatch" >&2 exit 1 } + target_permissions="$(gh api "repos/${TARGET_REPOSITORY}")" || { + echo "MESH_AGENT_IMAGES_DISPATCH_TOKEN cannot access ${TARGET_REPOSITORY}; update the secret with a credential that grants Contents write access" >&2 + exit 1 + } + [[ "$(jq -r '.permissions.push // false' <<<"$target_permissions")" == "true" ]] || { + echo "MESH_AGENT_IMAGES_DISPATCH_TOKEN must grant Contents write access to ${TARGET_REPOSITORY}; update the secret before retrying this release" >&2 + exit 1 + } jq -n \ --arg repository "$GITHUB_REPOSITORY" \ --arg ref "$RELEASE_TAG" \ @@ -1888,7 +1897,7 @@ jobs: }' | gh api \ --method POST \ - repos/Mesh-LLM/mesh-packaging/dispatches \ + "repos/${TARGET_REPOSITORY}/dispatches" \ --input - publish_crates_preflight: diff --git a/RELEASE.md b/RELEASE.md index 15f06c5be7..349694a0d9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -46,8 +46,9 @@ locally. - `MESH_AGENT_IMAGES_DISPATCH_TOKEN` configured as a fine-grained repository token or GitHub App token with Contents write access to `Mesh-LLM/mesh-packaging`, which is the permission required to create a - repository dispatch event. The legacy secret name is retained so existing - release environments do not require a coordinated secret rename. + repository dispatch event. The workflow checks this access without printing + the credential before it sends the event. The legacy secret name is retained + so existing release environments do not require a coordinated secret rename. ## Release Attestation Signing Keys @@ -253,6 +254,13 @@ have published successfully. Prereleases never dispatch it. The upstream `docker.yml` workflow performs Dockerfile validation only and is not a distribution channel. +If the downstream dispatch preflight fails, update the repository Actions secret +`MESH_AGENT_IMAGES_DISPATCH_TOKEN` with a fine-grained token or GitHub App token +that has Contents write access to `Mesh-LLM/mesh-packaging`, then retry the +failed dispatch job. A repository secret's presence does not prove that its +credential can write the target repository. Do not put the token in workflow +logs or command output. + On non-prerelease tags, the release workflow also publishes the Rust SDK crate chain to crates.io in dependency order: diff --git a/ci/ci.md b/ci/ci.md index c3563d7478..b982ddde45 100644 --- a/ci/ci.md +++ b/ci/ci.md @@ -188,6 +188,13 @@ the final notes retain the RC changes and add any post-RC changes. The workflow-scoped token push does not fan out another main CI run; the release graph is the evidence for that version-only source commit. +After a stable release with the full GPU matrix succeeds, the downstream +`mesh-packaging` dispatch job first checks that its +`MESH_AGENT_IMAGES_DISPATCH_TOKEN` credential can write the target repository. +That repository secret is external GitHub configuration. The checked-in +workflow can report a missing or insufficient credential, but it cannot grant +the token access or replace the secret. + ```mermaid flowchart TD JUST["just release VERSION
preflight + dispatch + wait"] --> DISPATCH["Release workflow dispatch"] diff --git a/scripts/tests/test_release_workflow_artifacts.py b/scripts/tests/test_release_workflow_artifacts.py index e62c5884c0..37a9e354a1 100644 --- a/scripts/tests/test_release_workflow_artifacts.py +++ b/scripts/tests/test_release_workflow_artifacts.py @@ -561,6 +561,30 @@ def test_prereleases_never_dispatch_downstream_publication(self) -> None: self.assertIn("publish_release_assets: true", dispatch) self.assertIn("publish_npm: true", dispatch) + def test_packaging_dispatch_preflights_target_write_access(self) -> None: + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + dispatch = job_block( + workflow, + "dispatch_packaging_release", + "publish_crates_preflight", + ) + + self.assertIn( + "GH_TOKEN: ${{ secrets.MESH_AGENT_IMAGES_DISPATCH_TOKEN }}", + dispatch, + ) + self.assertIn("TARGET_REPOSITORY: Mesh-LLM/mesh-packaging", dispatch) + self.assertIn( + 'target_permissions="$(gh api "repos/${TARGET_REPOSITORY}")"', + dispatch, + ) + self.assertIn(".permissions.push // false", dispatch) + self.assertIn( + "MESH_AGENT_IMAGES_DISPATCH_TOKEN must grant Contents write access", + dispatch, + ) + self.assertIn('"repos/${TARGET_REPOSITORY}/dispatches"', dispatch) + def test_release_assets_and_manual_tags_are_immutable(self) -> None: workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") publish = job_block( From 2877ac7d2777fd13545ec0d0af4ed26a70b15f8e Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Wed, 9 Sep 2026 19:22:09 -0400 Subject: [PATCH 30/41] ci: publish SDK smoke cache policy --- ci/slices.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/slices.yml b/ci/slices.yml index 203189ab16..7d1cceaf63 100644 --- a/ci/slices.yml +++ b/ci/slices.yml @@ -237,7 +237,7 @@ {"id": "platform-checks", "kind": "platform-checks", "depends_on": [], "runner_role": "platform-build", "cache_mode": "none"}, {"id": "product-smoke", "kind": "product-smoke", "depends_on": ["runtime-product"], "runner_role": "linux-build-4", "cache_mode": "none"}, {"id": "model-download", "kind": "model-download", "depends_on": [], "runner_role": "linux-build-4", "cache_mode": "none"}, - {"id": "sdk", "kind": "sdk", "depends_on": ["runtime-product"], "runner_role": "platform-build", "cache_mode": "none"}, + {"id": "sdk", "kind": "sdk", "depends_on": ["runtime-product"], "runner_role": "platform-build", "cache_mode": "pr-isolated"}, {"id": "runner-contract", "kind": "runner-contract", "depends_on": [], "runner_role": "linux-plan", "cache_mode": "none"} ] } From 1a2545d5566fd2104f4bff4296cd6be4aef16961 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:04:24 -0400 Subject: [PATCH 31/41] fix: retain device placement in native log summaries --- crates/skippy-runtime/src/logging.rs | 204 +++++++++++++++++++++------ 1 file changed, 164 insertions(+), 40 deletions(-) diff --git a/crates/skippy-runtime/src/logging.rs b/crates/skippy-runtime/src/logging.rs index 1df0480221..43a2d25d8a 100644 --- a/crates/skippy-runtime/src/logging.rs +++ b/crates/skippy-runtime/src/logging.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{CStr, c_char, c_int, c_void}; use std::fs::{File, OpenOptions}; use std::io::{LineWriter, Write}; @@ -185,6 +185,8 @@ struct NativeLogAggregator { metadata_progress: ProgressTracker, tensor_progress: ProgressTracker, layer_assign_progress: ProgressTracker, + layer_devices: BTreeMap, + layer_devices_emitted: bool, kv_cache_progress: ProgressTracker, metadata_in_dump: bool, metadata_summary_emitted: bool, @@ -239,6 +241,8 @@ impl NativeLogAggregator { self.metadata_progress.reset(); self.tensor_progress.reset(); self.layer_assign_progress.reset(); + self.layer_devices.clear(); + self.layer_devices_emitted = false; self.kv_cache_progress.reset(); self.metadata_in_dump = false; self.metadata_summary_emitted = false; @@ -257,12 +261,16 @@ impl NativeLogAggregator { let mut events = Vec::new(); let metadata_kv = parse_metadata_kv_line(s); let tensor_summary = parse_tensor_type_summary(s); + let layer_assignment = parse_layer_assignment(s); if metadata_kv.is_none() { events.extend(self.flush_metadata_summary()); } if tensor_summary.is_none() { events.extend(self.flush_tensor_group_summary()); } + if layer_assignment.is_none() { + events.extend(self.flush_layer_device_summary()); + } if let Some((metadata_rows, tensor_rows)) = parse_loaded_metadata_counts(s) { self.reset_model_loading_state(); @@ -321,24 +329,8 @@ impl NativeLogAggregator { return events; } - if let Some(layer_index) = parse_layer_assign_index(s) { - if self.layer_assign_progress.total.is_none() - && let Some(total) = self - .metadata_highlights - .block_count - .as_deref() - .and_then(|s| s.parse::().ok()) - { - self.layer_assign_progress.set_total(total); - } - let new_completed = layer_index + 1; - if new_completed > self.layer_assign_progress.completed { - let delta = new_completed - self.layer_assign_progress.completed; - events.extend( - self.layer_assign_progress - .advance(delta, "model", "layers", "layers"), - ); - } + if let Some((layer_index, device)) = layer_assignment { + events.extend(self.record_layer_assignment(layer_index, device)); return events; } @@ -353,6 +345,45 @@ impl NativeLogAggregator { events } + fn record_layer_assignment(&mut self, layer_index: usize, device: &str) -> Vec { + if self.layer_devices.get(&layer_index).map(String::as_str) != Some(device) { + self.layer_devices.insert(layer_index, device.to_string()); + self.layer_devices_emitted = false; + } + if self.layer_assign_progress.total.is_none() + && let Some(total) = self + .metadata_highlights + .block_count + .as_deref() + .and_then(|s| s.parse::().ok()) + { + self.layer_assign_progress.set_total(total); + } + let new_completed = layer_index.saturating_add(1); + let delta = new_completed.saturating_sub(self.layer_assign_progress.completed); + self.layer_assign_progress + .advance(delta, "model", "layers", "layers") + } + + fn flush_layer_device_summary(&mut self) -> Vec { + if self.layer_devices.is_empty() || self.layer_devices_emitted { + return Vec::new(); + } + self.layer_devices_emitted = true; + let mut counts = BTreeMap::new(); + for device in self.layer_devices.values() { + *counts.entry(device.as_str()).or_insert(0_u64) += 1; + } + vec![NativeLogEvent { + message: "Model layers by device".to_string(), + category: "model", + params: counts + .into_iter() + .map(|(device, count)| (device.to_string(), Value::from(count))) + .collect(), + }] + } + fn flush_metadata_summary(&mut self) -> Vec { if !self.metadata_in_dump || self.metadata_summary_emitted { return Vec::new(); @@ -475,7 +506,7 @@ fn summarize_native_log_line(line: &str) -> Option { if line.contains("VRAM") || line.contains("vram") || line.contains("mem_alloc") - || line.contains("_Mapped model buffer size") + || line.contains("model buffer size") || (line.contains("GPU") && line.contains("memory")) || line.contains("compute buffer size") || line.contains("scratch buffer") @@ -569,17 +600,12 @@ fn parse_tensor_type_summary(line: &str) -> Option<(&str, usize)> { Some((tensor_type.trim(), count)) } -fn parse_layer_assign_index(line: &str) -> Option { - if !line.starts_with("load_tensors: layer") || !line.contains("assigned to device") { - return None; - } - let (_, remainder) = line.split_once("load_tensors: layer")?; - let digits = remainder - .trim_start() - .chars() - .take_while(|ch| ch.is_ascii_digit()) - .collect::(); - (!digits.is_empty()).then(|| digits.parse().ok()).flatten() +fn parse_layer_assignment(line: &str) -> Option<(usize, &str)> { + let remainder = line.strip_prefix("load_tensors: layer")?; + let (index, device) = remainder.split_once("assigned to device")?; + let index = index.trim().parse().ok()?; + let device = device.split(',').next()?.trim(); + (!device.is_empty()).then_some((index, device)) } fn parse_kv_cache_layers_total(line: &str) -> Option { @@ -1061,6 +1087,96 @@ mod tests { ); } + #[test] + fn aggregator_preserves_model_buffers_for_all_devices() { + let mut aggregator = NativeLogAggregator::default(); + for device in ["CUDA0", "CUDA1", "Metal", "ROCm0", "Vulkan0", "CPU_Mapped"] { + let line = format!("load_tensors: {device} model buffer size = 4321.00 MiB"); + let events = aggregator.process_line(&line); + assert_eq!(events.len(), 1, "missing buffer for {device}"); + assert_eq!(events[0].message, line); + assert_eq!(events[0].category, "memory"); + } + } + + #[test] + fn aggregator_summarizes_unique_layer_assignments_including_output_layer() { + let mut aggregator = NativeLogAggregator::default(); + aggregator.process_line("llama_model_loader: - kv 0: qwen35.block_count u32 = 4"); + for (layer, device) in [ + (0, "CUDA0"), + (1, "CPU"), + (2, "CUDA0"), + (2, "CUDA0"), + (3, "CUDA1"), + (4, "CPU"), + ] { + let events = aggregator.process_line(&format!( + "load_tensors: layer {layer} assigned to device {device}, is_swa = 0" + )); + assert!( + events + .iter() + .all(|event| event.message != "Model layers by device") + ); + } + + assert_eq!( + aggregator.process_line("load_tensors: finished"), + vec![NativeLogEvent { + message: "Model layers by device".to_string(), + category: "model", + params: vec![ + ("CPU".to_string(), Value::from(2_u64)), + ("CUDA0".to_string(), Value::from(2_u64)), + ("CUDA1".to_string(), Value::from(1_u64)), + ], + }] + ); + assert!(aggregator.process_line("load_tensors: finished").is_empty()); + } + + #[test] + fn aggregator_emits_only_changed_layer_device_counts() { + let mut aggregator = NativeLogAggregator::default(); + aggregator.process_line("load_tensors: layer 0 assigned to device CUDA0"); + assert_eq!(aggregator.process_line("load_tensors: finished").len(), 1); + aggregator.process_line("load_tensors: layer 0 assigned to device CUDA0"); + assert!(aggregator.process_line("load_tensors: finished").is_empty()); + + aggregator.process_line("load_tensors: layer 0 assigned to device CPU"); + assert_eq!( + aggregator.process_line("load_tensors: finished"), + vec![NativeLogEvent { + message: "Model layers by device".to_string(), + category: "model", + params: vec![("CPU".to_string(), Value::from(1_u64))], + }] + ); + } + + #[test] + fn aggregator_resets_layer_devices_for_each_model() { + let mut aggregator = NativeLogAggregator::default(); + aggregator.process_line("load_tensors: layer 0 assigned to device CUDA0"); + let next_model = aggregator.process_line( + "llama_model_loader: loaded meta data with 1 key-value pairs and 1 tensors from next.gguf (version GGUF V3)", + ); + assert!(next_model.iter().any(|event| { + event.message == "Model layers by device" + && event.params == vec![("CUDA0".to_string(), Value::from(1_u64))] + })); + aggregator.process_line("load_tensors: layer 0 assigned to device Metal"); + assert_eq!( + aggregator.process_line("load_tensors: finished"), + vec![NativeLogEvent { + message: "Model layers by device".to_string(), + category: "model", + params: vec![("Metal".to_string(), Value::from(1_u64))], + }] + ); + } + #[test] fn aggregator_tags_cpu_offload_evidence_without_capacity_facts() { let mut aggregator = NativeLogAggregator::default(); @@ -1225,25 +1341,33 @@ mod tests { } #[test] - fn parse_layer_assign_index_extracts_layer_number() { + fn parse_layer_assignment_extracts_layer_and_device() { + assert_eq!( + parse_layer_assignment("load_tensors: layer 0 assigned to device CUDA0"), + Some((0, "CUDA0")) + ); assert_eq!( - parse_layer_assign_index("load_tensors: layer 0 assigned to device CUDA0"), - Some(0) + parse_layer_assignment("load_tensors: layer 63 assigned to device CUDA0, is_swa = 0"), + Some((63, "CUDA0")) ); assert_eq!( - parse_layer_assign_index("load_tensors: layer 63 assigned to device CUDA0"), - Some(63) + parse_layer_assignment("load_tensors: layer 5 assigned to device CPU, is_swa = 1"), + Some((5, "CPU")) ); assert_eq!( - parse_layer_assign_index("load_tensors: layer 5 assigned to device CPU"), - Some(5) + parse_layer_assignment("llm_load_tensors: offloaded 64/65 layers"), + None + ); + assert_eq!( + parse_layer_assignment("load_tensors: layer 0 computation graph"), + None ); assert_eq!( - parse_layer_assign_index("llm_load_tensors: offloaded 64/65 layers"), + parse_layer_assignment("load_tensors: layer x assigned to device CUDA0"), None ); assert_eq!( - parse_layer_assign_index("load_tensors: layer 0 computation graph"), + parse_layer_assignment("load_tensors: layer 0 assigned to device , is_swa = 0"), None ); } From 0bda645bdc1b9ee382bba3cd898a10b23a35ff41 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo <728690+ndizazzo@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:08:34 -0400 Subject: [PATCH 32/41] ci: stop duplicate release tag runs (#1731) --- .../manage-ci/references/current-inventory.md | 8 ++- .github/workflows/release.yml | 2 - RELEASE.md | 52 ++++++------------- ci/ci.md | 17 ++---- .../tests/test_release_workflow_artifacts.py | 9 ++++ 5 files changed, 31 insertions(+), 57 deletions(-) diff --git a/.agents/skills/manage-ci/references/current-inventory.md b/.agents/skills/manage-ci/references/current-inventory.md index 61709353f9..26e2802df1 100644 --- a/.agents/skills/manage-ci/references/current-inventory.md +++ b/.agents/skills/manage-ci/references/current-inventory.md @@ -21,7 +21,7 @@ Read it with `../SKILL.md` and `ci/ci.md` before editing CI. | `main_windows.yml` (`Main · Windows`) | push to `main` | Exhaustive main planning plus the same-commit reusable Windows lane | | `ci.yml` | `workflow_call` only | Temporary inert shim for the former main ingress filename; pending protected-main runner-contract update; no push trigger or dispatch | | `ci-control.yml` (`CI · Manual Full`) | dispatch on default branch | Explicit operator-only full plan, bounded lane dispatch and correlated diagnostic checks | -| `release.yml` | release tags, dispatch | Canonical version synchronization, release-only signing, assets, publication, and a preflighted downstream `mesh-packaging` dispatch | +| `release.yml` | dispatch on the default branch | Canonical version synchronization, release-only signing, assets, publication, and a preflighted downstream `mesh-packaging` dispatch | | `website-pages.yml` | main website paths, dispatch | Public website deployment | | `pr_cleanup.yml` | PR close, dispatch | Positively matched cleanup only | | `pr_auto_assign.yml` | PR lifecycle | Metadata only | @@ -118,10 +118,8 @@ For a non-canary manual dispatch, `release.yml` runs the checked-in `scripts/release-version.sh`, creates one linear release-source commit when the tracked version surface changes, and fast-forwards `main` before any release build starts. `just release` is a preflight and synchronous dispatcher for that -same workflow. A tag-push release is read-only with respect to `main` and is -accepted only when the tag is already reachable from `main` and applying the -same version script produces no tracked diff. Canary dispatches never update -`main` or publish. The publish job creates only the release-specific tag commit +same workflow. Canary dispatches never update `main` or publish. The publish job +creates only the release-specific tag commit for generated Swift/SDK resources and enables GitHub-generated release notes. The comparison base is the highest stable `vMAJOR.MINOR.PATCH` tag below the target; prerelease tags are excluded so RC and final notes use the same stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 533e080f3b..c7d80b26a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,6 @@ name: Release # GPU runner; unset or false uses GitHub-hosted runners. on: - push: - tags: ['v*'] workflow_dispatch: inputs: version: diff --git a/RELEASE.md b/RELEASE.md index 349694a0d9..97001375e5 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -29,12 +29,11 @@ dispatch the full packaging matrix. Do not use GitHub's bare **Draft a new release** form as an alternate release path. It bypasses the verified artifact graph. The Release workflow is the only -supported GitHub release publisher; the Actions UI, `just release`, and a -pre-versioned tag all enter that workflow. +supported GitHub release publisher; use the Actions UI or `just release` to +dispatch it from `main`. -The sections below document the underlying steps. They matter when releasing -manually via a tag push, debugging the workflow, or validating bundles -locally. +The sections below document the underlying steps, workflow debugging, and local +bundle validation. ## Prerequisites @@ -152,9 +151,11 @@ just release-build-cuda just release-bundle-cuda v0.X.Y ``` -Before manually cutting a tag that should be consumable through SwiftPM, -prepare the Swift binary target manifest on macOS and commit the resulting -`Package.swift` change: +Before dispatching a release that should be consumable through SwiftPM, +prepare the Swift binary target manifest on macOS and land the resulting +`Package.swift` change on `main`. This is local preparation only; it publishes +nothing, and the release itself is still cut by dispatching the Release +workflow, which creates the tag: ```bash scripts/prepare-swift-package-release.sh v0.X.Y @@ -169,10 +170,10 @@ exact platform and architecture slices plus the macOS framework layout, runs a zipped-artifact SwiftPM consumer smoke, and checks that the tagged `Package.swift` already points at the exact release URL and checksum. The producer also uploads the generated `mesh_ffi.swift` as a separate immutable -companion artifact. Main and tag builds fail when that generated binding drifts -from the tracked source. If `Package.swift` still contains placeholders on a -tag push, if the generated binding is stale, or if the checksum does not match -the artifact built in release CI, the release fails before publishing. +companion artifact. Main release builds fail when that generated binding drifts +from the tracked source. If the generated binding is stale, or if the checksum +does not match the artifact built in release CI, the release fails before +publishing. Producer and smoke use the pinned `macos-15` image and an explicit native/Xcode cache epoch. Downstream Swift smoke consumes both verified producer artifacts and never compiles an XCFramework replacement. @@ -231,23 +232,8 @@ Verify: ## Publish -Push a `v*` tag to run `.github/workflows/release.yml`. This lower-level path is -accepted only when the tag points to `main` history and already contains the -complete matching version update. Prepare and commit it before creating the -tag: - -```bash -scripts/release-version.sh v0.X.Y -git add --update -git commit -m "v0.X.Y: prepare release source" -git push origin main -git tag v0.X.Y -git push origin v0.X.Y -``` - -The workflow rejects version-drifted tags instead of publishing binaries whose -source version disagrees with the release. The upstream release workflow owns -release archive production, but it does not publish OCI images. +The dispatched release workflow owns release archive production, but it does +not publish OCI images. `Mesh-LLM/mesh-packaging` is the canonical package, GHCR, and npm producer. It starts only after a stable GitHub release and its complete CPU/GPU archive set have published successfully. Prereleases never dispatch it. The upstream @@ -282,14 +268,6 @@ the canonical SDK resource locations: `sdk/node/console`, `sdk/swift/Sources/MeshLLM/Resources/Console`, and `sdk/kotlin/src/main/resources/mesh-llm/console`. -These generated directories are ignored during normal development. For a -manual tag push, force-add them into the release commit before tagging because -SwiftPM resolves package resources from the Git tag: - -```bash -git add -f sdk/node/console sdk/swift/Sources/MeshLLM/Resources/Console sdk/kotlin/src/main/resources/mesh-llm/console -``` - Workflow-dispatch releases generate and force-add these resources into the release tag commit automatically. diff --git a/ci/ci.md b/ci/ci.md index b982ddde45..5aff87f2c8 100644 --- a/ci/ci.md +++ b/ci/ci.md @@ -152,10 +152,8 @@ Release efficiency TODOs: surface. On a non-canary `release.yml` dispatch, the metadata job applies that script, creates a linear release-source commit when needed, and fast-forwards `main` before the build graph begins. `just release` only performs local -preflight, dispatches that workflow, and waits for its result. A tag push must -already be reachable from `main` and version-complete; the metadata job applies -the same script and rejects any tracked diff. Canary dispatches do not mutate -`main` or publish. +preflight, dispatches that workflow, and waits for its result. Canary dispatches +do not mutate `main` or publish. Release calls the existing UI producer once with the immutable source SHA and release tag. It prepares that version, builds the TypeScript console in release @@ -199,24 +197,17 @@ the token access or replace the secret. flowchart TD JUST["just release VERSION
preflight + dispatch + wait"] --> DISPATCH["Release workflow dispatch"] UI["GitHub Actions UI"] --> DISPATCH - TAG["Pre-versioned v* tag push"] --> VERIFY["Verify tag is on main history
and already version-complete"] DISPATCH --> META["Resolve version and highest prior stable notes tag"] - VERIFY --> META - META --> PATH{"Release path"} + META --> PATH{"Canary?"} PATH -- "canary dispatch" --> CANARY["Use dispatch SHA
do not update main"] PATH -- "non-canary dispatch" --> BUMP["Run release-version.sh"] BUMP --> VERSION_COMMIT["Commit tracked version surface
fast-forward main"] - PATH -- "tag push" --> TAG_SOURCE["Use validated tag source"] CANARY --> BUILD["Build, compose, and smoke artifact matrix"] VERSION_COMMIT --> BUILD - TAG_SOURCE --> BUILD BUILD --> PUBLISHABLE{"Canary?"} PUBLISHABLE -- "yes" --> CANARY_DONE["Stop without tag or publication"] - PUBLISHABLE -- "no" --> TAG_PATH{"Entry path"} - TAG_PATH -- "dispatch" --> PREPARE_TAG["Add generated SDK resources
create and push immutable tag"] - TAG_PATH -- "tag push" --> EXISTING_TAG["Use existing immutable tag"] + PUBLISHABLE -- "no" --> PREPARE_TAG["Add generated SDK resources
create and push immutable tag"] PREPARE_TAG --> RELEASE["Publish GitHub release
notes compare from prior stable tag"] - EXISTING_TAG --> RELEASE RELEASE --> KIND{"Prerelease?"} KIND -- "yes" --> RC_DONE["Stop after GitHub prerelease"] KIND -- "no" --> DOWNSTREAM["Publish crates and dispatch
packages, images, and npm"] diff --git a/scripts/tests/test_release_workflow_artifacts.py b/scripts/tests/test_release_workflow_artifacts.py index 37a9e354a1..b6652cb0b5 100644 --- a/scripts/tests/test_release_workflow_artifacts.py +++ b/scripts/tests/test_release_workflow_artifacts.py @@ -20,6 +20,15 @@ def job_block(workflow: str, job_name: str, next_job_name: str) -> str: class ReleaseWorkflowArtifactTests(unittest.TestCase): + def test_release_is_dispatch_only(self) -> None: + document = yaml.safe_load(RELEASE_WORKFLOW.read_text(encoding="utf-8")) + # YAML 1.1 resolves an unquoted `on` key to the boolean True. + triggers = document.get("on", document.get(True)) + + self.assertIsInstance(triggers, dict) + self.assertIn("workflow_dispatch", triggers) + self.assertNotIn("push", triggers) + def test_release_ui_version_preparation_handles_container_ownership(self) -> None: workflow = yaml.safe_load( (ROOT / ".github/workflows/ci-ui-artifact-slice.yml").read_text() From aec8c50d06ebf9414b4a5b9e7bcc0b5b06b1c378 Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 00:50:58 +1000 Subject: [PATCH 33/41] fix(gpu-tune): preserve throughput profile ubatch --- .../tune/recommendation_defaults_tests.rs | 31 ++++++++++++++++++- .../src/gpus/tune/recommendation_writes.rs | 20 ++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs b/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs index 04feaf7ec8..d0e1087b27 100644 --- a/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs +++ b/crates/mesh-llm-commands/src/gpus/tune/recommendation_defaults_tests.rs @@ -1,6 +1,6 @@ use mesh_llm_config::{ FlashAttentionType, HardwareConfig, IntegerOrString, MeshConfig, ModelConfigDefaults, - ModelConfigEntry, ModelFitConfig, + ModelConfigEntry, ModelFitConfig, ThroughputConfig, }; use super::*; @@ -26,6 +26,35 @@ fn gpu_tune_recommends_stable_defaults() { assert_applied_fit_target(&plan, 22 * 1024); } +#[test] +fn gpu_tune_does_not_shadow_throughput_profile_ubatch() { + let config = MeshConfig { + defaults: Some(ModelConfigDefaults { + throughput: Some(ThroughputConfig { + tuning_profile: Some("throughput".to_string()), + ..ThroughputConfig::default() + }), + ..ModelConfigDefaults::default() + }), + models: vec![ModelConfigEntry { + model: "hf://mesh/example.gguf".to_string(), + ..ModelConfigEntry::default() + }], + ..MeshConfig::default() + }; + let plan = build_tune_plan(TuneRecommendationInput { + apply_mode: TuneApplyMode::ApplyMissing, + config: &config, + target: &recommendation_target(true), + metadata: &sample_metadata(8 * gib(), 32, 131_072, 0), + hardware: &gpu_hardware(24 * gib()), + survey: &survey_with_gpu(24 * gib(), 64 * gib()), + }); + + assert_applied_batch(&plan, 512); + assert_preserved(&plan, TuneField::Ubatch, "throughput tuning profile"); +} + #[test] fn gpu_tune_uses_q4_policy_for_large_models() { let plan = build_tune_plan(TuneRecommendationInput { diff --git a/crates/mesh-llm-commands/src/gpus/tune/recommendation_writes.rs b/crates/mesh-llm-commands/src/gpus/tune/recommendation_writes.rs index 8bd6ead853..d3dee9278c 100644 --- a/crates/mesh-llm-commands/src/gpus/tune/recommendation_writes.rs +++ b/crates/mesh-llm-commands/src/gpus/tune/recommendation_writes.rs @@ -132,6 +132,16 @@ pub(crate) fn push_batch_statuses( existing_ubatch_source(model_entry, defaults), ), ] { + if field == TuneField::Ubatch + && source.is_none() + && effective_tuning_profile(model_entry, defaults).is_some() + { + plan.field_statuses.push(TuneFieldStatus::Preserved { + field, + reason: "the effective throughput tuning profile remains authoritative".to_string(), + }); + continue; + } if let Some(source) = source && apply_mode != TuneApplyMode::ReplaceExisting { @@ -158,6 +168,16 @@ pub(crate) fn push_batch_statuses( } } +fn effective_tuning_profile<'a>( + model_entry: Option<&'a ModelConfigEntry>, + defaults: Option<&'a ModelConfigDefaults>, +) -> Option<&'a str> { + model_entry + .and_then(|entry| entry.throughput.as_ref()) + .and_then(|throughput| throughput.tuning_profile.as_deref()) + .or_else(|| defaults?.throughput.as_ref()?.tuning_profile.as_deref()) +} + pub(crate) fn push_gpu_layers_status( plan: &mut TunePlan, apply_mode: TuneApplyMode, From 33544560cc561e6ce247f82cb659ad575fe7d080 Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 12:22:46 +1000 Subject: [PATCH 34/41] ci: sync protected SDK cache policy --- ci/slices.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/slices.yml b/ci/slices.yml index 203189ab16..7d1cceaf63 100644 --- a/ci/slices.yml +++ b/ci/slices.yml @@ -237,7 +237,7 @@ {"id": "platform-checks", "kind": "platform-checks", "depends_on": [], "runner_role": "platform-build", "cache_mode": "none"}, {"id": "product-smoke", "kind": "product-smoke", "depends_on": ["runtime-product"], "runner_role": "linux-build-4", "cache_mode": "none"}, {"id": "model-download", "kind": "model-download", "depends_on": [], "runner_role": "linux-build-4", "cache_mode": "none"}, - {"id": "sdk", "kind": "sdk", "depends_on": ["runtime-product"], "runner_role": "platform-build", "cache_mode": "none"}, + {"id": "sdk", "kind": "sdk", "depends_on": ["runtime-product"], "runner_role": "platform-build", "cache_mode": "pr-isolated"}, {"id": "runner-contract", "kind": "runner-contract", "depends_on": [], "runner_role": "linux-plan", "cache_mode": "none"} ] } From 6f101924c7ffb6ba083dbb15a31ce3d2543ecb3c Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 14:21:17 +1000 Subject: [PATCH 35/41] chore(skippy): consolidate durable L3 review From ab2405055499ac54a5af2140fcaeb2bb8da781ca Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 14:23:09 +1000 Subject: [PATCH 36/41] chore(skippy): validate consolidated durable L3 From 1414471d5049033530a61b35d1c8725dd8cb440a Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 14:25:19 +1000 Subject: [PATCH 37/41] ci: synchronize conventional commit validator --- scripts/check-conventional-commit.py | 234 +++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100755 scripts/check-conventional-commit.py diff --git a/scripts/check-conventional-commit.py b/scripts/check-conventional-commit.py new file mode 100755 index 0000000000..dd5ca2862a --- /dev/null +++ b/scripts/check-conventional-commit.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Validate a commit message against Conventional Commits v1.0.0. + +Release notes are regrouped deterministically from the canonical squash-merge +commit subjects, so the type/scope prefix on a subject is release metadata, not +decoration. See .agents/skills/release-notes/SKILL.md. + +Usage: + check-conventional-commit.py + check-conventional-commit.py --message "fix(skippy): restore prefix reuse" + check-conventional-commit.py --range origin/main..HEAD +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys + +# Closed set: every type maps to exactly one Keep a Changelog section. +TYPES = { + "feat": "Added", + "fix": "Fixed", + "perf": "Changed", + "security": "Security", + "revert": "Changed", + "refactor": "Internal", + "style": "Internal", + "test": "Internal", + "build": "Internal", + "deps": "Internal", + "ci": "Internal", + "chore": "Internal", + "docs": "Internal", +} + +SUBJECT_RE = re.compile( + r"^(?P[a-z]+)" + r"(?:\((?P[a-z0-9][a-z0-9._/-]*)\))?" + r"(?P!)?" + r": (?P.+)$" +) + +# Git and the release workflow author these; they are never release entries. +EXEMPT_RE = re.compile( + r"^(Merge |Revert \"|fixup! |squash! |amend! )" + r"|^v?\d+\.\d+\.\d+[^:]*: prepare release source$" +) + +MAX_SUBJECT = 100 +TRAILING_PR_RE = re.compile(r"\s*\(#\d+\)$") + +# Attribution trailers for agents, bots, and relay identities. GitHub adds +# these automatically when squashing a PR whose commits carry them, so they +# have to be kept out of the branch commits in the first place. +TRAILER_RE = re.compile( + r"^(?P[A-Za-z][A-Za-z-]*-by)\s*:\s*(?P[^<]*?)\s*(?:<(?P[^>]*)>)?\s*$", + re.IGNORECASE, +) + +# Any address at these domains, including subdomains. +DENIED_DOMAINS = ("buzz.xyz",) + +DENIED_ADDRESSES = ("noreply@anthropic.com", "noreply@coderabbit.ai") + +# Matched against whole name tokens, so "Sol" is denied but "Solomon" is not. +DENIED_NAMES = ( + "claude", + "anthropic", + "chatgpt", + "openai", + "codex", + "copilot", + "sisyphus", + "astra", + "sol", + "luna", + "terra", + "coderabbit", + "coderabbitai", + "devin", + "cursor", +) + +NAME_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def denied_identity(name, email): + """Return why this trailer identity is denied, or None if it is allowed.""" + email = (email or "").strip().lower() + name = (name or "").strip() + + if email: + if email in DENIED_ADDRESSES: + return f"'{email}' is an agent attribution address" + domain = email.rpartition("@")[2] + for denied in DENIED_DOMAINS: + if domain == denied or domain.endswith("." + denied): + return f"'{domain}' is a relay identity domain" + if "[bot]" in email: + return f"'{email}' is a bot account" + + tokens = set(NAME_TOKEN_RE.findall(name.lower())) + hits = tokens & set(DENIED_NAMES) + if hits: + return f"'{name}' names an agent or bot ({', '.join(sorted(hits))})" + if "[bot]" in name.lower(): + return f"'{name}' is a bot account" + return None + + +def check_trailers(lines): + """Return a list of problems with the attribution trailers in a message.""" + problems = [] + for line in lines: + match = TRAILER_RE.match(line.strip()) + if not match: + continue + reason = denied_identity(match.group("name"), match.group("email")) + if reason: + problems.append(f"drop '{line.strip()}': {reason}") + return problems + + +def check_subject(subject): + """Return a list of problems with one commit subject.""" + if not subject.strip(): + return ["empty commit subject"] + if EXEMPT_RE.search(subject): + return [] + + # GitHub appends "(#1234)" when squash-merging; judge the authored part. + authored = TRAILING_PR_RE.sub("", subject) + match = SUBJECT_RE.match(authored) + if not match: + return [ + "subject is not Conventional Commits v1.0.0", + " expected: (): ", + f" received: {subject}", + f" types: {', '.join(sorted(TYPES))}", + ] + + problems = [] + kind = match.group("type") + if kind not in TYPES: + problems.append( + f"unknown type '{kind}'; use one of: {', '.join(sorted(TYPES))}" + ) + description = match.group("description") + if description.endswith("."): + problems.append("description must not end with a period") + if description[0].isupper() and not description.split()[0].isupper(): + problems.append("description should start lowercase unless it is a proper noun") + if len(authored) > MAX_SUBJECT: + problems.append( + f"subject is {len(authored)} characters; keep it under {MAX_SUBJECT}" + ) + return problems + + +def report(subject, problems, stream=sys.stderr): + print(f"commit message rejected: {subject}", file=stream) + for problem in problems: + print(f" {problem}", file=stream) + print( + "\nConventional Commits: https://www.conventionalcommits.org/en/v1.0.0/\n" + "The type decides which release-notes section the change lands in.\n" + "Add a 'Release-Notes:
' trailer to override, or\n" + "'BREAKING CHANGE: ' for a breaking change.\n" + "\nCatch this at commit time instead of in CI: just hooks-install\n" + "Agent, bot, and relay attribution trailers are not kept in this\n" + "history. GitHub re-adds them when squashing a PR whose commits carry\n" + "them, so remove them from the branch commits.\n" + "Bypass once with --no-verify if you know the commit is not a release entry.", + file=stream, + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("file", nargs="?", help="path to a commit message file") + source.add_argument("--message", help="validate this subject directly") + source.add_argument("--range", help="validate every commit in a git range") + parser.add_argument( + "--trailers-only", + action="store_true", + help="check only attribution trailers, not subject format", + ) + args = parser.parse_args() + + if args.range: + out = subprocess.run( + ["git", "log", "--format=%B%x1e", "--no-merges", args.range], + capture_output=True, + text=True, + check=True, + ).stdout + failed = False + for record in out.split("\x1e"): + lines = [line for line in record.strip("\n").splitlines() if line.strip()] + if not lines: + continue + problems = check_trailers(lines) if args.trailers_only else ( + check_subject(lines[0]) + check_trailers(lines[1:]) + ) + if problems: + report(lines[0], problems) + failed = True + return 1 if failed else 0 + + if args.message is not None: + lines = args.message.splitlines() if args.message.strip() else [] + else: + with open(args.file, encoding="utf-8") as handle: + lines = [ + line + for line in handle.read().splitlines() + if not line.startswith("#") + ] + subject = lines[0] if lines else "" + + problems = check_trailers(lines) if args.trailers_only else ( + check_subject(subject) + check_trailers(lines[1:]) + ) + if problems: + report(subject, problems) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f8637d37b8b3ff1bfe8324366d2047749f4dc6c2 Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 16:42:04 +1000 Subject: [PATCH 38/41] fix(skippy): address consolidated L3 review findings Preserve routable aliases for same-model workers, serialize packed GC with publication, reconcile direct store opens, and harden replay and canary validation boundaries. --- .../src/inference/skippy/certification.rs | 54 +++++- .../mesh-llm-host-runtime/src/mesh/gossip.rs | 14 +- .../mesh/tests/gossip/merge_and_refresh.rs | 20 ++ .../src/models/profile.rs | 8 + .../network/openai/moa_gateway/self_fill.rs | 173 ++++++++++++------ .../openai/moa_gateway/self_fill/tests.rs | 43 +++++ .../src/network/openai/routing_rank.rs | 25 +++ .../src/runtime/proxy/tests/mod.rs | 3 +- crates/mesh-llm-system/src/hardware/tests.rs | 59 +++++- crates/skippy-cache/src/l3.rs | 18 +- crates/skippy-cache/src/l3/packed.rs | 6 +- crates/skippy-cache/src/l3/tests.rs | 29 ++- crates/skippy-cache/src/manager.rs | 2 +- evals/kv-restart-replay.py | 28 ++- scripts/llama-canary-agent-repair.sh | 53 +++++- scripts/tests/test_kv_restart_replay.py | 98 ++++++++++ ...test_llama_canary_agent_repair_contract.py | 20 ++ tools/xtask/data/console_print_allowlist.json | 2 +- 18 files changed, 542 insertions(+), 113 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs b/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs index 899fb4a4fe..46032f3219 100644 --- a/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs +++ b/crates/mesh-llm-host-runtime/src/inference/skippy/certification.rs @@ -579,7 +579,7 @@ mod tests { use crate::inference::skippy::materialization::{StagePackageInfo, StagePackageLayerInfo}; use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; + use tokio::net::{TcpListener, TcpStream}; #[test] fn certification_ranges_split_two_stage_package() { @@ -835,6 +835,34 @@ mod tests { format!("http://{addr}") } + async fn read_complete_http_request(stream: &mut TcpStream) -> Vec { + let mut request = Vec::new(); + loop { + let mut chunk = [0u8; 4096]; + let n = stream.read(&mut chunk).await.unwrap(); + assert!(n > 0, "unexpected EOF while reading certification request"); + request.extend_from_slice(&chunk[..n]); + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") + else { + continue; + }; + let body_start = header_end + 4; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() >= body_start + content_length { + return request; + } + } + } + /// Mimics a node that only recognizes `served_model_id` — anything else 404s, /// the same way a real host does when a client asks for a model name it /// doesn't advertise. @@ -844,9 +872,8 @@ mod tests { tokio::spawn(async move { for _ in 0..3 { let (mut stream, _) = listener.accept().await.unwrap(); - let mut buf = [0u8; 4096]; - let n = stream.read(&mut buf).await.unwrap(); - let request = String::from_utf8_lossy(&buf[..n]); + let request = read_complete_http_request(&mut stream).await; + let request = String::from_utf8_lossy(&request); let response = if request.starts_with("GET") { let body = json!({ "object": "list", @@ -900,4 +927,23 @@ mod tests { assert_eq!(gate.status, CertificationGateStatus::Passed, "{gate:?}"); } } + + #[tokio::test] + async fn certification_stub_reads_a_body_split_from_its_headers() { + let model = "hf://meshllm/split-request@abc123"; + let api_base = spawn_certification_stub_server(model.to_string()).await; + let addr = api_base.strip_prefix("http://").unwrap(); + let body = json!({"model": model, "messages": []}).to_string(); + let headers = format!( + "POST /v1/chat/completions HTTP/1.1\r\nHost: {addr}\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(headers.as_bytes()).await.unwrap(); + tokio::task::yield_now().await; + stream.write_all(body.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 200 OK")); + } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/gossip.rs b/crates/mesh-llm-host-runtime/src/mesh/gossip.rs index d39a96f15f..a8c1eff997 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/gossip.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/gossip.rs @@ -321,7 +321,6 @@ pub(super) fn apply_transitive_ann( existing.hosted_models_known = ann.hosted_models.is_some(); existing.role = ann.role.clone(); merge_first_joined_mesh_ts(&mut existing.first_joined_mesh_ts, ann.first_joined_mesh_ts); - let capacity_changed = existing.vram_bytes != ann.vram_bytes; existing.vram_bytes = ann.vram_bytes; // Only advance addr if the transitive announcement is at least as path-rich, // so a direct peer's richer address is not overwritten by a weaker transitive one. @@ -346,15 +345,10 @@ pub(super) fn apply_transitive_ann( if ann.gpu_reserved_bytes.is_some() { existing.gpu_reserved_bytes = ann.gpu_reserved_bytes.clone(); } - match ann.memory { - Some(memory) => existing.memory = Some(memory), - // A relay that predates the block strips it. The cached block only - // explains the capacity it arrived with: keep it while that capacity - // is unchanged, drop it once the capacity moved, so a stale breakdown - // is never paired with the new budget and rebroadcast as such. - None if capacity_changed => existing.memory = None, - None => {} - } + // A transitive announcement is a complete snapshot at its revision. An + // omitted memory block must clear an older cached value; retaining it can + // rebroadcast a breakdown the announcing peer no longer claims. + existing.memory = ann.memory; if ann.gpu_mem_bandwidth_gbps.is_some() { existing.gpu_mem_bandwidth_gbps = ann.gpu_mem_bandwidth_gbps.clone(); } diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip/merge_and_refresh.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip/merge_and_refresh.rs index d2a43f714a..dd0afb2050 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip/merge_and_refresh.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip/merge_and_refresh.rs @@ -73,6 +73,26 @@ pub(crate) fn test_merge_equal_values_unchanged() { assert_eq!(existing.first_joined_mesh_ts, Some(100)); } +#[test] +pub(crate) fn test_transitive_snapshot_clears_omitted_memory_breakdown() { + let mut existing = test_peer(Some(100)); + existing.memory = Some(crate::mesh::AdvertisedMemory { + total_bytes: 1024, + usable_bytes: 1024, + ..Default::default() + }); + let ann = test_announcement(Some(100)); + + apply_transitive_ann( + &mut existing, + &test_addr(0x33), + &ann, + test_endpoint_id(0xee), + ); + + assert_eq!(existing.memory, None); +} + #[test] pub(crate) fn test_meaningfully_changed_first_joined_mesh_ts() { let old_peer = test_peer(Some(100)); diff --git a/crates/mesh-llm-host-runtime/src/models/profile.rs b/crates/mesh-llm-host-runtime/src/models/profile.rs index 6ed3c90783..c379988670 100644 --- a/crates/mesh-llm-host-runtime/src/models/profile.rs +++ b/crates/mesh-llm-host-runtime/src/models/profile.rs @@ -90,6 +90,7 @@ fn resolve_parameter_size( parameter_count: Option, ) -> Option { source_size + .and_then(non_empty) .or_else(|| parameter_count.and_then(parameter_size_from_count)) .or_else(|| parameter_size_from_text(model_name)) } @@ -190,6 +191,13 @@ mod tests { resolve_parameter_size("model-7B", None, None).as_deref(), Some("7B") ); + for blank in ["", " ", "\t\n"] { + assert_eq!( + resolve_parameter_size("model-7B", Some(blank.to_string()), Some(8_000_000_000),) + .as_deref(), + Some("8B") + ); + } } #[test] diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs index f94c01332c..2f7ce3a467 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill.rs @@ -1,9 +1,10 @@ //! Same-model committees: distinct physical clones, reserved for the turn. +use super::pool::canonical_base_name; use super::workers::{LocalModelBackend, RemoteModelBackend, ReservedModelBackend}; use crate::inference::election::{InferenceTarget, ModelTargets}; use crate::mesh; use crate::network::affinity::AffinityRouter; -use crate::network::openai::routing_rank::rank_targets_by_context; +use crate::network::openai::routing_rank::rank_aliased_targets_by_context; use crate::network::reservations::RoutingReservation; use mesh_mixture_of_agents as moa; use std::sync::Arc; @@ -11,6 +12,7 @@ use std::sync::Arc; /// Measured self-MoA width; fleet capacity must not increase fan-out cost. const SELF_FILL_TARGET_WORKERS: usize = 2; +#[cfg(test)] async fn select_clones( node: &mesh::Node, name: &str, @@ -18,6 +20,29 @@ async fn select_clones( candidates: Vec, affinity: Option<&AffinityRouter>, ) -> Vec<(InferenceTarget, Option)> { + select_aliased_clones( + node, + &canonical_base_name(name), + required_tokens, + candidates + .into_iter() + .map(|target| (target, name.to_string())) + .collect(), + affinity, + ) + .await + .into_iter() + .map(|(target, _, reservation)| (target, reservation)) + .collect() +} + +async fn select_aliased_clones( + node: &mesh::Node, + reservation_key: &str, + required_tokens: Option, + candidates: Vec<(InferenceTarget, String)>, + affinity: Option<&AffinityRouter>, +) -> Vec<(InferenceTarget, String, Option)> { use crate::proto::node::InferenceAdmissionState; let deprioritized: std::collections::HashSet<_> = node @@ -32,36 +57,48 @@ async fn select_clones( // Preserve admission priority before context/throughput ranking. Local and // legacy peers stay healthy; hosts_for_model already excludes paused peers. let (mut healthy, mut spillover): (Vec<_>, Vec<_>) = candidates.into_iter().partition( - |target| !matches!(target, InferenceTarget::Remote(id) if deprioritized.contains(id)), + |(target, _)| !matches!(target, InferenceTarget::Remote(id) if deprioritized.contains(id)), ); let mut selected = Vec::with_capacity(SELF_FILL_TARGET_WORKERS); while selected.len() < SELF_FILL_TARGET_WORKERS { // Exhaust context-eligible healthy endpoints before considering spillover, // even when every healthy clone already has reservations from other turns. - let mut ranked = rank_targets_by_context(node, name, required_tokens, &healthy).await; + let mut ranked = rank_aliased_targets_by_context(node, required_tokens, &healthy).await; if ranked.ordered.is_empty() { - ranked = rank_targets_by_context(node, name, required_tokens, &spillover).await; + ranked = rank_aliased_targets_by_context(node, required_tokens, &spillover).await; } let Some(preferred) = ranked.ordered.first() else { break; }; + let physical = ranked + .ordered + .iter() + .map(|(target, _)| target.clone()) + .collect::>(); + let preferred_target = &preferred.0; let (target, reservation) = affinity .and_then(|router| { router.reserve_route( - name, - &ranked.ordered, + reservation_key, + &physical, ranked.equivalent_prefix, - preferred, + preferred_target, false, ) }) .map(|(target, guard)| (target, Some(guard))) - .unwrap_or_else(|| (preferred.clone(), None)); + .unwrap_or_else(|| (preferred_target.clone(), None)); + let alias = ranked + .ordered + .iter() + .find(|(candidate, _)| candidate == &target) + .map(|(_, alias)| alias.clone()) + .expect("reserved aliased target came from ranked candidates"); // Selection+reservation is atomic per slot; removing the endpoint // prevents duplicate workers even when other turns interleave slots. - healthy.retain(|candidate| candidate != &target); - spillover.retain(|candidate| candidate != &target); - selected.push((target, reservation)); + healthy.retain(|(candidate, _)| candidate != &target); + spillover.retain(|(candidate, _)| candidate != &target); + selected.push((target, alias, reservation)); } selected } @@ -78,61 +115,85 @@ pub(super) async fn self_fill_from_extra_instances( let Some(existing) = models.first().cloned() else { return; }; - let name = &existing.name; + let base = canonical_base_name(&existing.name); + let mut aliases = node.models_being_served().await; + if let Some(targets) = targets { + aliases.extend(targets.targets.keys().cloned()); + } + aliases.retain(|alias| canonical_base_name(alias) == base); + aliases.push(existing.name.clone()); + aliases.sort_by(|a, b| { + (b == &existing.name) + .cmp(&(a == &existing.name)) + .then_with(|| a.len().cmp(&b.len())) + .then_with(|| a.cmp(b)) + }); + aliases.dedup(); let mut candidates = Vec::new(); - if let Some(local) = targets - .and_then(|targets| targets.targets.get(name)) - .and_then(|targets| { - targets - .iter() - .find(|t| matches!(t, InferenceTarget::Local(_))) - }) - { - candidates.push(local.clone()); + for alias in &aliases { + if let Some(local) = targets + .and_then(|targets| targets.targets.get(alias)) + .and_then(|targets| { + targets + .iter() + .find(|t| matches!(t, InferenceTarget::Local(_))) + }) + { + candidates.push((local.clone(), alias.clone())); + } + candidates.extend( + node.hosts_for_model(alias) + .await + .into_iter() + .map(|peer_id| (InferenceTarget::Remote(peer_id), alias.clone())), + ); } - candidates.extend( - node.hosts_for_model(name) - .await - .into_iter() - .map(InferenceTarget::Remote), - ); + let mut physical = Vec::new(); + candidates.retain(|(target, _)| { + if physical.contains(target) { + false + } else { + physical.push(target.clone()); + true + } + }); if candidates.len() < 2 { return; } - let selected = select_clones(node, name, required_tokens, candidates, affinity).await; + let selected = select_aliased_clones(node, &base, required_tokens, candidates, affinity).await; if selected.len() < 2 { return; // Context filtering must not fabricate a second worker. } - *backends = selected - .into_iter() - .map(|(target, reservation)| { - let inner: Arc = match target { - InferenceTarget::Local(port) => Arc::new(LocalModelBackend { - port, - http: http.clone(), - }), - // No failover onto a sibling slot: every worker is a distinct sample. - InferenceTarget::Remote(peer_id) => Arc::new(RemoteModelBackend { - node: node.clone(), - peer_ids: vec![peer_id], - }), - InferenceTarget::None => unreachable!("self-fill only collects physical endpoints"), - }; - match reservation { - Some(reservation) => Arc::new(ReservedModelBackend { - inner, - _reservation: reservation, - }) as Arc, - None => inner, - } - }) - .collect(); - *models = (0..backends.len()) - .map(|backend_index| moa::ModelEntry { + let mut filled_backends = Vec::with_capacity(selected.len()); + let mut filled_models = Vec::with_capacity(selected.len()); + for (backend_index, (target, name, reservation)) in selected.into_iter().enumerate() { + let inner: Arc = match target { + InferenceTarget::Local(port) => Arc::new(LocalModelBackend { + port, + http: http.clone(), + }), + // No failover onto a sibling slot: every worker is a distinct sample. + InferenceTarget::Remote(peer_id) => Arc::new(RemoteModelBackend { + node: node.clone(), + peer_ids: vec![peer_id], + }), + InferenceTarget::None => unreachable!("self-fill only collects physical endpoints"), + }; + filled_backends.push(match reservation { + Some(reservation) => Arc::new(ReservedModelBackend { + inner, + _reservation: reservation, + }) as Arc, + None => inner, + }); + filled_models.push(moa::ModelEntry { + name, backend_index, ..existing.clone() - }) - .collect(); + }); + } + *backends = filled_backends; + *models = filled_models; } #[cfg(test)] diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs index 01203d60f8..64b79e0a10 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs @@ -17,6 +17,49 @@ async fn fleet(count: u32) -> (mesh::Node, Vec) { (node, candidates) } +fn rename_peer_model(peer: &mut mesh::PeerInfo, name: &str) { + peer.models = vec![name.to_string()]; + peer.serving_models = vec![name.to_string()]; + peer.hosted_models = vec![name.to_string()]; + for descriptor in &mut peer.served_model_descriptors { + descriptor.identity.model_name = name.to_string(); + } + for runtime in &mut peer.served_model_runtime { + runtime.model_name = name.to_string(); + } +} + +#[tokio::test] +async fn self_fill_preserves_each_physical_workers_routable_alias() { + let node = mesh::Node::new_for_tests(mesh::NodeRole::Client) + .await + .unwrap(); + let model = BIG_MODELS[1]; + let short = model.name; + let long = "unsloth/Qwen3-32B-GGUF:Q4_K_M"; + assert_eq!( + super::super::pool::canonical_base_name(short), + super::super::pool::canonical_base_name(long) + ); + let first = fleet_peer_with_health(1, model, None, Some(100_000)); + let mut second = fleet_peer_with_health(2, model, None, Some(100_000)); + rename_peer_model(&mut second, long); + node.insert_test_peer(first).await; + node.insert_test_peer(second).await; + + let (backends, models) = + assemble_worker_pool(&node, None, Some(13_000), &reqwest::Client::new(), None).await; + + assert_eq!(backends.len(), 2); + assert_eq!( + models + .iter() + .map(|model| model.name.as_str()) + .collect::>(), + HashSet::from([short, long]) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_committees_spread_across_twenty_clones() { let (node, candidates) = fleet(20).await; diff --git a/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs b/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs index 952cf0603d..93c3b00cfe 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/routing_rank.rs @@ -309,6 +309,31 @@ pub(super) async fn rank_targets_by_context( rank_candidates_by_context_and_throughput(&candidates, required_tokens) } +pub(super) async fn rank_aliased_targets_by_context( + node: &mesh::Node, + required_tokens: Option, + targets: &[(election::InferenceTarget, String)], +) -> RankedCandidates<(election::InferenceTarget, String)> { + let mut candidates = Vec::with_capacity(targets.len()); + for (target, model) in targets { + let context_length = match target { + election::InferenceTarget::Local(_) => node.local_model_context_length(model).await, + election::InferenceTarget::Remote(peer_id) => { + node.peer_model_context_length(*peer_id, model).await + } + election::InferenceTarget::None => None, + }; + let throughput = match target { + election::InferenceTarget::Remote(peer_id) => { + remote_target_throughput_rank(node, model, *peer_id).await + } + _ => local_target_throughput_rank(node, model, target), + }; + candidates.push(((target.clone(), model.clone()), context_length, throughput)); + } + rank_candidates_by_context_and_throughput(&candidates, required_tokens) +} + #[cfg(test)] pub(super) async fn order_targets_by_context( node: &mesh::Node, diff --git a/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs b/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs index b38afcf387..1212a16eab 100644 --- a/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs +++ b/crates/mesh-llm-host-runtime/src/runtime/proxy/tests/mod.rs @@ -378,7 +378,8 @@ async fn spawn_held_upstream( tokio::spawn(async move { let _raw = read_raw_http_request(&mut stream).await; accepted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let _permit = release.acquire().await.expect("release semaphore"); + let permit = release.acquire().await.expect("release semaphore"); + permit.forget(); let reply = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), diff --git a/crates/mesh-llm-system/src/hardware/tests.rs b/crates/mesh-llm-system/src/hardware/tests.rs index 4cd862c13d..e4d34af4d1 100644 --- a/crates/mesh-llm-system/src/hardware/tests.rs +++ b/crates/mesh-llm-system/src/hardware/tests.rs @@ -1,6 +1,55 @@ use super::*; use serial_test::serial; +#[cfg(all( + target_os = "linux", + any( + not(feature = "skippy-devices"), + feature = "dynamic-native-runtime", + test + ) +))] +struct TestDirectory(std::path::PathBuf); + +#[cfg(all( + target_os = "linux", + any( + not(feature = "skippy-devices"), + feature = "dynamic-native-runtime", + test + ) +))] +impl TestDirectory { + fn new(label: &str) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("test clock follows Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "mesh-llm-{label}-{}-{timestamp}-{id}", + std::process::id() + )); + std::fs::create_dir(&path).expect("create collision-resistant test directory"); + Self(path) + } +} + +#[cfg(all( + target_os = "linux", + any( + not(feature = "skippy-devices"), + feature = "dynamic-native-runtime", + test + ) +))] +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + fn synthetic_gpu(index: usize, stable_id: Option<&str>) -> GpuFacts { GpuFacts { index, @@ -414,11 +463,12 @@ fn test_tegra_collector_gpu_name_absent_leaves_source_none() { // model file present, which made the old `TegraCollector.collect` form flip // on such a host). With the model file absent, both the name and its source // must stay absent — never a guessed source for a name that was never read. - let missing = std::path::Path::new("/nonexistent/mesh-llm/tegra/devicetree/base/model"); + let directory = TestDirectory::new("tegra-model-absent"); + let missing = directory.0.join("missing-model"); assert!(!missing.exists()); let mut survey = HardwareSurvey::default(); - tegra_gpu_name_from_model_path(&mut survey, missing); + tegra_gpu_name_from_model_path(&mut survey, &missing); assert_eq!(survey.gpu_name, None); assert_eq!(survey.gpu_name_source, None); @@ -439,7 +489,8 @@ fn test_tegra_collector_gpu_name_absent_leaves_source_none() { fn test_tegra_collector_gpu_name_present_tags_sysfs_source() { use std::io::Write as _; - let path = std::env::temp_dir().join("mesh_llm_test_tegra_model_present"); + let directory = TestDirectory::new("tegra-model-present"); + let path = directory.0.join("model"); let mut f = std::fs::File::create(&path).expect("create temp model file"); write!(f, "NVIDIA Jetson AGX Orin Developer Kit\0").expect("write model file"); drop(f); @@ -447,8 +498,6 @@ fn test_tegra_collector_gpu_name_present_tags_sysfs_source() { let mut survey = HardwareSurvey::default(); tegra_gpu_name_from_model_path(&mut survey, &path); - let _ = std::fs::remove_file(&path); - assert_eq!(survey.gpu_name.as_deref(), Some("Jetson AGX Orin")); assert_eq!(survey.gpu_name_source, Some(GpuNameSource::Sysfs)); } diff --git a/crates/skippy-cache/src/l3.rs b/crates/skippy-cache/src/l3.rs index b2301a8e15..870b12e952 100644 --- a/crates/skippy-cache/src/l3.rs +++ b/crates/skippy-cache/src/l3.rs @@ -482,6 +482,15 @@ impl HandoffSegmentStore { /// filesystem: both break the atomic-rename and containment assumptions /// every later guarantee rests on, and neither is worth a partial mode. pub fn open_with_limits(root: impl Into, limits: StoreLimits) -> Result { + let store = Self::open_unreconciled_with_limits(root, limits)?; + store.reconcile_startup()?; + Ok(store) + } + + pub(crate) fn open_unreconciled_with_limits( + root: impl Into, + limits: StoreLimits, + ) -> Result { let root = root.into(); if !root.is_absolute() { bail!("cache root must be absolute: {}", root.display()); @@ -1627,7 +1636,14 @@ impl HandoffSegmentStore { .into_iter() .collect::>(); freed = freed.saturating_add(self.packed.remove_orphan_indexes(&manifests)?); - freed = freed.saturating_add(self.packed.remove_orphan_packs(&referenced, &held)?); + freed = freed.saturating_add(self.packed.remove_orphan_packs(&referenced, || { + self.inflight_segments + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .keys() + .cloned() + .collect() + })?); Ok(freed) } } diff --git a/crates/skippy-cache/src/l3/packed.rs b/crates/skippy-cache/src/l3/packed.rs index 6a29bee6f7..4d92d2fd87 100644 --- a/crates/skippy-cache/src/l3/packed.rs +++ b/crates/skippy-cache/src/l3/packed.rs @@ -447,12 +447,16 @@ impl PackedSegmentStore { pub(super) fn remove_orphan_packs( &self, referenced_segments: &HashSet, - held_segments: &HashSet, + held_segments: impl FnOnce() -> HashSet, ) -> Result { let _mutation = self .mutation .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + // Snapshot in-flight publications only after taking the same lock that + // serializes pack publication, so collection cannot race a completed + // pack into existence before its manifest is committed. + let held_segments = held_segments(); let locations = self .locations .read() diff --git a/crates/skippy-cache/src/l3/tests.rs b/crates/skippy-cache/src/l3/tests.rs index 1bb7eada8c..1c003a62c3 100644 --- a/crates/skippy-cache/src/l3/tests.rs +++ b/crates/skippy-cache/src/l3/tests.rs @@ -116,10 +116,9 @@ fn packed_roundtrip_uses_one_physical_file_and_survives_reopen() { manifest }; + // Direct callers receive a fully reconciled store, including the packed + // location map needed to read manifests from the previous process. let reopened = store(&root, 0); - reopened - .reconcile_startup() - .expect("reconcile packed store"); let loaded = reopened .load_manifest(&manifest.payload_digest) .expect("load packed manifest after restart"); @@ -326,28 +325,24 @@ fn a_segment_larger_than_the_budget_is_refused() { #[test] fn an_entry_larger_than_a_shrunken_budget_is_refused_at_commit() { - // A budget shrunk between runs is the realistic way an entry ends up - // bigger than the cap: it was admissible when its segments were - // written and is not any more. + // A live budget update can make an in-flight entry larger than the cap: + // it was admissible when its segments were written and is not any more. let root = temp_root("oversize-commit"); - let uncapped = store(&root, 0); - let manifest = { - let (manifest, held) = manifest_for(&uncapped, &vec![7u8; 16_000], 4_000); - drop(held); - manifest - }; - drop(uncapped); - - let capped = store(&root, 8_000); - let error = capped + let store = store(&root, 0); + let (manifest, held) = manifest_for(&store, &vec![7u8; 16_000], 4_000); + store + .update_limits(StoreLimits::new(8_000, 0)) + .expect("shrink limits"); + let error = store .commit(&manifest) .expect_err("an entry larger than the budget was committed"); + drop(held); assert!( format!("{error:#}").contains("skipped_oversize"), "refusal did not carry the reason code: {error:#}" ); assert!( - capped.list_manifests().expect("list").is_empty(), + store.list_manifests().expect("list").is_empty(), "the refused entry was left loadable" ); } diff --git a/crates/skippy-cache/src/manager.rs b/crates/skippy-cache/src/manager.rs index 31ffafbb4f..25bb56eb6c 100644 --- a/crates/skippy-cache/src/manager.rs +++ b/crates/skippy-cache/src/manager.rs @@ -430,7 +430,7 @@ fn open_store_for_acquire( let attempts = if expiring_owner { HANDOFF_ATTEMPTS } else { 1 }; let mut last = None; for attempt in 0..attempts { - match HandoffSegmentStore::open_with_limits(root, limits) { + match HandoffSegmentStore::open_unreconciled_with_limits(root, limits) { Ok(store) => return Ok(store), Err(error) => { last = Some(error); diff --git a/evals/kv-restart-replay.py b/evals/kv-restart-replay.py index 8a5cf380da..21410078c4 100644 --- a/evals/kv-restart-replay.py +++ b/evals/kv-restart-replay.py @@ -282,6 +282,8 @@ def stream_request( completion_tokens = 0 prompt_tokens = 0 cached_tokens = 0 + saw_prompt_tokens = False + saw_cached_tokens = False saw_done = False connection = http.client.HTTPConnection(DEFAULT_HOST, DEFAULT_PORT, timeout=timeout) payload = { @@ -325,10 +327,17 @@ def stream_request( usage = event.get("usage") if isinstance(usage, dict): completion_tokens = int(usage.get("completion_tokens") or completion_tokens) - prompt_tokens = int(usage.get("prompt_tokens") or prompt_tokens) + if "prompt_tokens" in usage and usage["prompt_tokens"] is not None: + prompt_tokens = int(usage["prompt_tokens"]) + saw_prompt_tokens = True details = usage.get("prompt_tokens_details") - if isinstance(details, dict): - cached_tokens = int(details.get("cached_tokens") or cached_tokens) + if ( + isinstance(details, dict) + and "cached_tokens" in details + and details["cached_tokens"] is not None + ): + cached_tokens = int(details["cached_tokens"]) + saw_cached_tokens = True choices = event.get("choices") if not isinstance(choices, list) or not choices: continue @@ -339,6 +348,10 @@ def stream_request( return {"request_id": request_id, "error": "stream completed without content tokens"} if not saw_done: return {"request_id": request_id, "error": "stream ended without terminal [DONE] marker"} + if not saw_prompt_tokens: + return {"request_id": request_id, "error": "stream completed without prompt token usage"} + if not saw_cached_tokens: + return {"request_id": request_id, "error": "stream completed without cached token usage"} ended = time.monotonic() return { "request_id": request_id, @@ -578,10 +591,7 @@ def replay_frozen(cohort: str, model_id: str, repeats: Optional[int] = None) -> replay_frozen("warm", model_id, repeats=max(args.restore_repeats - 1, 0)) finally: if process is not None: - try: - stop_server(process) - except RuntimeError: - pass + stop_server(process) provenance["cohorts"] = [ summarize_cohort("fill", [row for row in rows if row["cohort"] == "fill"]), @@ -649,8 +659,8 @@ def main() -> int: args = parser.parse_args() output = args.output.resolve() - if (output / "run.json").exists(): - raise SystemExit(f"output already contains run.json: {output}") + if output.exists() and any(output.iterdir()): + raise SystemExit(f"output directory is not empty: {output}") output.mkdir(parents=True, exist_ok=True) run = run_arm(args, output) diff --git a/scripts/llama-canary-agent-repair.sh b/scripts/llama-canary-agent-repair.sh index 1d549bd63e..63c07bdf56 100755 --- a/scripts/llama-canary-agent-repair.sh +++ b/scripts/llama-canary-agent-repair.sh @@ -102,7 +102,7 @@ gh_repair() { } check_repair_token_permissions() { - local login default_branch head_sha probe_branch probe_ref + local login default_branch head_sha probe_branch probe_ref probe_pr login="$(gh_repair gh api user --jq .login 2>/dev/null)" || { echo "preflight: CANARY_REPAIR_TOKEN does not authenticate" >&2 return 1 @@ -120,6 +120,15 @@ check_repair_token_permissions() { "repos/${GITHUB_REPOSITORY:?}/git/refs/heads%2F${probe_branch}" >/dev/null 2>&1; then echo "preflight: WARNING: could not delete temporary ref ${probe_ref}" >&2 fi + probe_pr="$(gh_repair gh api "repos/${GITHUB_REPOSITORY:?}/pulls?state=all&per_page=1" --jq '.[0].number' 2>/dev/null)" || { + echo "preflight: could not select a pull request for the Pull requests: write capability probe" >&2 + return 1 + } + if [[ -z "$probe_pr" ]] || ! printf '%s\n' '{}' | gh_repair gh api --method PATCH \ + "repos/${GITHUB_REPOSITORY:?}/pulls/${probe_pr}" --input - >/dev/null 2>&1; then + echo "preflight: identity '${login}' lacks Pull requests: write on ${GITHUB_REPOSITORY}" >&2 + return 1 + fi echo "preflight: repair token identity '${login}' verified read+write on ${GITHUB_REPOSITORY}" } @@ -145,6 +154,30 @@ run_bounded() { --seconds "$seconds" --label "$label" -- "$@" } +remaining_publication_seconds() { + local remaining + remaining="$((DEADLINE_AT - $(date +%s)))" + (( remaining > 0 )) || return 1 + printf '%s\n' "$remaining" +} + +run_publication_bounded() { + local label="$1" seconds + shift + if ! seconds="$(remaining_publication_seconds)"; then + echo "$label cannot start: internal canary deadline reached" >&2 + return 124 + fi + python3 scripts/run-command-with-timeout.py \ + --seconds "$seconds" --label "$label" -- "$@" +} + +gh_repair_bounded() { + local label="$1" + shift + GH_TOKEN="$CANARY_REPAIR_TOKEN" run_publication_bounded "$label" "$@" +} + run_logged() { local label="$1" log="$2" shift 2 @@ -305,7 +338,8 @@ Failure evidence (tail): } current_pr() { - gh_repair gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true + gh_repair_bounded "find terminal canary PR" gh pr list \ + --head "$BRANCH" --state open --json number --jq '.[0].number' } commit_terminal_tree() { @@ -330,7 +364,8 @@ publish_terminal_branch() { if [[ "$outcome" == "certified" ]]; then CERTIFIED_SHA="$PUBLISHED_SHA" fi - if ! GIT_ASKPASS="$GIT_ASKPASS_SCRIPT" GIT_TERMINAL_PROMPT=0 \ + if ! run_publication_bounded "push terminal canary branch" env \ + GIT_ASKPASS="$GIT_ASKPASS_SCRIPT" GIT_TERMINAL_PROMPT=0 \ git push "https://github.com/${GITHUB_REPOSITORY}.git" \ "HEAD:refs/heads/${BRANCH}" 2> >(redact_token >&2); then echo "ERROR: could not push ${BRANCH}; the identity behind CANARY_REPAIR_TOKEN needs Contents and pull-request write access" >&2 @@ -381,7 +416,8 @@ ensure_pr() { local -a create_args pr="$(current_pr)" if [[ -n "$pr" ]]; then - gh_repair gh pr edit "$pr" --body-file "$PR_BODY" >/dev/null + gh_repair_bounded "update terminal canary PR" gh pr edit \ + "$pr" --body-file "$PR_BODY" >/dev/null printf '%s\n' "$pr" return 0 fi @@ -392,7 +428,8 @@ ensure_pr() { title="draft(llama): failed canary at ${UPSTREAM_SHA:0:10}" create_args=(--draft) fi - if ! created="$(gh_repair gh pr create --base main --head "$BRANCH" "${create_args[@]}" \ + if ! created="$(gh_repair_bounded "create terminal canary PR" gh pr create \ + --base main --head "$BRANCH" "${create_args[@]}" \ --title "$title" --body-file "$PR_BODY" 2> >(redact_token >&2))"; then echo "ERROR: could not create the terminal canary PR for ${BRANCH}" >&2 return 1 @@ -409,7 +446,8 @@ verify_pr_head() { pr="$(current_pr)" [[ -n "$pr" ]] || { echo "terminal canary PR was not created" >&2; return 1; } for attempt in 1 2 3; do - remote_head="$(gh_repair gh pr view "$pr" --json headRefOid --jq .headRefOid 2>/dev/null || true)" + remote_head="$(gh_repair_bounded "verify terminal canary PR head" gh pr view \ + "$pr" --json headRefOid --jq .headRefOid 2>/dev/null || true)" [[ "$remote_head" == "$expected" ]] && return 0 sleep "$attempt" done @@ -428,7 +466,8 @@ report_terminal() { else comment="**Uncertified terminal state.** The internal deadline or repair-turn limit stopped the \`${FAILED_PHASE}\` phase. This draft preserves the final attempted bytes and must not merge until the complete state machine passes." fi - gh_repair gh pr comment "$pr" --body "$comment" >/dev/null 2>&1 || true + gh_repair_bounded "comment on terminal canary PR" gh pr comment \ + "$pr" --body "$comment" >/dev/null 2>&1 || true echo "terminal canary PR #${pr}: ${outcome}; branch=${BRANCH}; head=${PUBLISHED_SHA}" } diff --git a/scripts/tests/test_kv_restart_replay.py b/scripts/tests/test_kv_restart_replay.py index 16dd743203..82019b01ab 100644 --- a/scripts/tests/test_kv_restart_replay.py +++ b/scripts/tests/test_kv_restart_replay.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import json import subprocess import sys import tempfile @@ -103,6 +104,47 @@ def close(self): self.assertEqual(result["error"], "stream ended without terminal [DONE] marker") + def test_stream_request_requires_prompt_and_cached_usage(self) -> None: + class Response: + status = 200 + + def __init__(self, usage): + self.usage = usage + + def __iter__(self): + return iter( + [ + b'data: {"choices":[{"delta":{"content":"ok"}}]}\n', + f'data: {json.dumps({"choices": [], "usage": self.usage})}\n'.encode(), + b"data: [DONE]\n", + ] + ) + + class Connection: + usage = {} + + def __init__(self, *_args, **_kwargs): + pass + + def request(self, *_args, **_kwargs): + pass + + def getresponse(self): + return Response(self.usage) + + def close(self): + pass + + for usage, expected in [ + ({"prompt_tokens_details": {"cached_tokens": 0}}, "prompt token usage"), + ({"prompt_tokens": 10}, "cached token usage"), + ]: + with self.subTest(expected=expected): + Connection.usage = usage + with mock.patch.object(BENCH.http.client, "HTTPConnection", Connection): + result = BENCH.stream_request("request", [], "model", 8, 10) + self.assertIn(expected, result["error"]) + def test_single_restore_sample_does_not_report_p95(self) -> None: summary = BENCH.summarize_cohort( "restore", @@ -179,6 +221,62 @@ def fake_stream(request_id, messages, *_args): self.assertEqual(calls[1][1], calls[2][1]) self.assertEqual(calls[2][1], calls[3][1]) + def test_run_arm_propagates_final_server_shutdown_failure(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary = root / "mesh-llm" + model = root / "model.gguf" + binary.write_bytes(b"binary") + model.write_bytes(b"model") + args = SimpleNamespace( + binary=str(binary), + model=str(model), + turns=1, + turn_target_tokens=32, + system_tokens=16, + restore_repeats=1, + max_output_tokens=8, + request_timeout=10.0, + ready_timeout=10.0, + serve_extra_args=[], + ) + with ( + mock.patch.object(BENCH, "start_server", return_value=(SimpleNamespace(), [])), + mock.patch.object(BENCH, "wait_for_model", return_value="model"), + mock.patch.object(BENCH, "stop_server", side_effect=[None, RuntimeError("did not stop")]), + mock.patch.object( + BENCH, + "stream_request", + return_value={ + "request_id": "fill-1", + "ttft_seconds": 0.1, + "total_seconds": 0.2, + "prompt_tokens": 10, + "completion_tokens": 1, + "cached_tokens": 0, + "decode_tokens_per_second": 10.0, + }, + ), + mock.patch.object(BENCH, "binary_provenance", return_value={"source_sha": "a" * 40}), + mock.patch.object(BENCH, "hardware_fingerprint", return_value={"platform": "test"}), + ): + with self.assertRaisesRegex(RuntimeError, "did not stop"): + BENCH.run_arm(args, root / "output") + + def test_main_rejects_any_nonempty_output_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + output = root / "output" + output.mkdir() + (output / "partial.log").write_text("partial", encoding="utf-8") + with mock.patch.object( + sys, + "argv", + ["kv-restart-replay.py", "--model", str(root / "model.gguf"), "--output", str(output)], + ): + with self.assertRaisesRegex(SystemExit, "not empty"): + BENCH.main() + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_llama_canary_agent_repair_contract.py b/scripts/tests/test_llama_canary_agent_repair_contract.py index 46b2d8f767..802602f8a8 100644 --- a/scripts/tests/test_llama_canary_agent_repair_contract.py +++ b/scripts/tests/test_llama_canary_agent_repair_contract.py @@ -253,10 +253,30 @@ def test_every_github_call_is_token_scoped(self) -> None: def test_token_permission_probe_is_unique_and_runs_before_work(self) -> None: self.assertIn("canary-repair-token-preflight-${RUN_KEY}", self.wrapper) self.assertIn("git/refs/heads%2F${probe_branch}", self.wrapper) + self.assertIn("Pull requests: write capability probe", self.wrapper) + self.assertIn("--method PATCH", self.wrapper) + self.assertIn('"repos/${GITHUB_REPOSITORY:?}/pulls/${probe_pr}"', self.wrapper) call = self.wrapper.index("check_repair_token_permissions\n") self.assertLess(call, self.wrapper.index("agent_turn()")) self.assertLess(call, self.wrapper.index("run_prepare()")) + def test_terminal_network_operations_share_the_publication_deadline(self) -> None: + terminal = self.wrapper[ + self.wrapper.index("current_pr() {") : self.wrapper.index('phase="prepare"') + ] + self.assertIn("remaining_publication_seconds", self.wrapper) + self.assertIn("run_publication_bounded", terminal) + self.assertIn("gh_repair_bounded", terminal) + for operation in ( + "push terminal canary branch", + "find terminal canary PR", + "update terminal canary PR", + "create terminal canary PR", + "verify terminal canary PR head", + "comment on terminal canary PR", + ): + self.assertIn(operation, terminal) + def test_dispatch_sha_is_rejected_before_use(self) -> None: crafted = "not-a-sha; echo pwned" result = subprocess.run( diff --git a/tools/xtask/data/console_print_allowlist.json b/tools/xtask/data/console_print_allowlist.json index 7c3eafa525..f25799e9fb 100644 --- a/tools/xtask/data/console_print_allowlist.json +++ b/tools/xtask/data/console_print_allowlist.json @@ -3699,7 +3699,7 @@ ], "crates/skippy-cache/src/l3/tests.rs": [ { - "line": 605, + "line": 600, "macro_name": "println!" } ], From 312b17666cd71397b9d65d8436e69735b169adf8 Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 18:42:12 +1000 Subject: [PATCH 39/41] chore(ci): retrigger consolidated L3 validation From 15aa71195d7cb241965ee3a0f69e91d618b52019 Mon Sep 17 00:00:00 2001 From: scama Date: Sat, 12 Sep 2026 18:58:55 +1000 Subject: [PATCH 40/41] test(mesh): align review regressions with runtime contracts Update transitive-memory assertions for omission clearing and compare self-fill output with the public aliases that peers actually advertise. --- .../mesh-llm-host-runtime/src/mesh/tests/peer_state.rs | 9 ++++----- .../src/network/openai/moa_gateway/self_fill/tests.rs | 9 +++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs index 64f0e2bf6d..27d7c9d389 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs @@ -1859,9 +1859,8 @@ fn transitive_peer_update_refreshes_memory_only_when_advertised() { let mut ann = peer_state_test_announcement(addr.clone()); apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); assert_eq!( - existing.memory, - Some(advertised), - "a relay without the block keeps the last advertised one" + existing.memory, None, + "an omitted block cannot prove the cached breakdown is current" ); let refreshed = crate::mesh::AdvertisedMemory { @@ -1905,10 +1904,10 @@ fn transitive_peer_update_drops_the_cached_memory_when_the_capacity_moves() { "a stale breakdown must not be paired with a new capacity" ); - // The same relay with the unchanged capacity keeps the block. + // An unchanged capacity is not provenance for the omitted breakdown. existing.memory = Some(advertised); apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); - assert_eq!(existing.memory, Some(advertised)); + assert_eq!(existing.memory, None); } #[test] diff --git a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs index 64b79e0a10..6bf4b76e37 100644 --- a/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs +++ b/crates/mesh-llm-host-runtime/src/network/openai/moa_gateway/self_fill/tests.rs @@ -44,6 +44,11 @@ async fn self_fill_preserves_each_physical_workers_routable_alias() { let first = fleet_peer_with_health(1, model, None, Some(100_000)); let mut second = fleet_peer_with_health(2, model, None, Some(100_000)); rename_peer_model(&mut second, long); + let expected_aliases = first + .http_routable_models() + .into_iter() + .chain(second.http_routable_models()) + .collect::>(); node.insert_test_peer(first).await; node.insert_test_peer(second).await; @@ -54,9 +59,9 @@ async fn self_fill_preserves_each_physical_workers_routable_alias() { assert_eq!( models .iter() - .map(|model| model.name.as_str()) + .map(|model| model.name.clone()) .collect::>(), - HashSet::from([short, long]) + expected_aliases ); } From 342010a0b2418ee2ff8500909f6e26aa9337d0d7 Mon Sep 17 00:00:00 2001 From: James Dumay Date: Sat, 12 Sep 2026 22:18:02 +1000 Subject: [PATCH 41/41] chore(lockfile): include L2 cache dependency --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 0d2908fb6a..35276c0521 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7391,6 +7391,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "skippy-cache", "skippy-protocol", "skippy-runtime", "skippy-topology",