diff --git a/docs/architecture/LOCAL-MODEL-RESERVE.md b/docs/architecture/LOCAL-MODEL-RESERVE.md index d28f8236..557e43f0 100644 --- a/docs/architecture/LOCAL-MODEL-RESERVE.md +++ b/docs/architecture/LOCAL-MODEL-RESERVE.md @@ -97,3 +97,29 @@ ausdrücklich gestartete tatsächliche Aufgabe mit Tool-/Ergebnis-/Leasebeleg. Rückweg: laufenden Versuch an sicherer Grenze beenden, Policy ausdrücklich deaktivieren oder den vorherigen integrierten Stand einsetzen. Das alte Verhalten ohne Reservensperre wird dadurch nicht zu einer Kapazitätsfreigabe. + + +## Pfadprüfung über den installierten API-Einstieg + +Die Ressourcenprüfung bezieht den TaskDB-Pfad direkt aus der bestehenden +Pfadautorität `hub.bach_paths.BACH_DB`. Sie benötigt keinen privaten Export +aus `bach_api`. Der Root-Einstieg für editable Installationen exportiert +nur die öffentliche API; eine andere Importreihenfolge darf die +Ressourcenprüfung deshalb nicht verändern. Slot- und Runtimepfade folgen +weiterhin ihrer vorhandenen Konfiguration. Die Pfadprüfung öffnet oder +migriert keine Datenbank und legt fehlende Dateien nicht an. + +Die Regression verwendet einen eigenen Prozess mit dem echten Root-Einstieg +vor `system`, isolierten TaskDB-/Slot-/Runtimepfaden und einem gesperrten +Datenbankzugriff. Sie prüft sowohl die Dateisystemwurzeln als auch eine +Ressourcenprobe mit synthetischen Modell- und Kapazitätswerten. Diese Prüfung +belegt den Importvertrag, keine echte Inferenz oder Kapazität des Hauptmodells. + +Am 12.10.2026 wurden die native Ressourcenpolicy und die Modellsteckplatz- +Registry explizit aktiviert. Der danach gestartete Buddha-Versuch für #2120 +scheiterte vor der Inferenz am privaten API-Import; die Task blieb offen und +ihre Lease wurde zurückgegeben. Die vorherigen Metadatenproben verwendeten +einen anderen Importkontext und deckten diesen Fehler nicht ab. +Eine Quellenkorrektur allein startet den Worker nicht neu. Nach regulärer +Integration sind der tatsächliche Control-Importkontext, ein ausdrücklich +gestarteter Lauf, Werkzeugwirkung und Ergebnis erneut nachzuweisen. diff --git a/system/hub/_services/chat/local_resource_reserve.py b/system/hub/_services/chat/local_resource_reserve.py index b130fd89..33752684 100644 --- a/system/hub/_services/chat/local_resource_reserve.py +++ b/system/hub/_services/chat/local_resource_reserve.py @@ -101,9 +101,9 @@ def read_policy(): def _data_roots(): # Path resolution does not open or migrate the canonical task database. from hub._services.chat.slots_config import DEFAULT_SLOTS_FILE + from hub.bach_paths import BACH_DB - from bach_api import _resolve_db_path - roots = {"taskdb": _resolve_db_path().parent, "runtime": runtime_root(), + roots = {"taskdb": Path(BACH_DB).expanduser().parent, "runtime": runtime_root(), "slots": Path(DEFAULT_SLOTS_FILE).expanduser().parent} result = {} for label, root in roots.items(): diff --git a/system/tests/test_local_resource_reserve.py b/system/tests/test_local_resource_reserve.py index 13e55a7a..073f4e4b 100644 --- a/system/tests/test_local_resource_reserve.py +++ b/system/tests/test_local_resource_reserve.py @@ -2,7 +2,12 @@ """Fake model metadata and isolated files; no real inference or task writes.""" import asyncio import json +import os +import subprocess +import sys +import textwrap from copy import deepcopy +from pathlib import Path from types import SimpleNamespace import httpx @@ -572,3 +577,107 @@ async def forbidden(*_, **__): pytest.fail("An internal gate wait cannot authori asyncio.run(runtime._chat_with_compute_turn(backend, [])) assert runtime._compute_turn_gate.active is False assert runtime.compute_turn_status()["active"] is False + + +@pytest.mark.parametrize("operation", ["roots", "probe"]) +def test_root_api_shim_resource_paths_without_database_access(tmp_path, operation): + """Exercise the editable-install facade instead of the system-only import.""" + source_root = Path(__file__).resolve().parents[2] + database_dir = tmp_path / "database" + slots_dir = tmp_path / "slots" + runtime_dir = tmp_path / "runtime" + for directory in (database_dir, slots_dir, runtime_dir): + directory.mkdir() + isolated_env = os.environ.copy() + isolated_env.update({ + "BACH_DB": str(database_dir / "absent.db"), + "BACH_LOCAL_DIR": str(database_dir), + "BACH_RUNTIME_DIR": str(runtime_dir), + "BACH_SLOTS_CONFIG_PATH": str(slots_dir / "absent.json"), + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONIOENCODING": "utf-8", + "PYTHONPATH": os.pathsep.join(filter(None, ( + str(source_root), str(source_root / "system"), + isolated_env.get("PYTHONPATH", ""), + ))), + }) + script = textwrap.dedent(""" + import asyncio + import json + import os + import sqlite3 + import sys + from pathlib import Path + from types import SimpleNamespace + + def forbidden_database(*args, **kwargs): + raise AssertionError("Resource path resolution must not open a DB") + + sqlite3.connect = forbidden_database + import bach_api + source_root = Path(sys.argv[1]) + assert Path(bach_api.__file__).resolve() == source_root / "bach_api.py" + assert not hasattr(bach_api, "_resolve_db_path") + from hub.bach_paths import BACH_DB + from hub._services.chat import local_resource_reserve as reserve + from hub._services.llm.model_backend import OllamaBackend + expected = { + "taskdb": Path(os.environ["BACH_DB"]).parent, + "slots": Path(os.environ["BACH_SLOTS_CONFIG_PATH"]).parent, + "runtime": Path(os.environ["BACH_RUNTIME_DIR"]).expanduser().resolve(), + } + # The process guard may supply its own isolated runtime directory. + expected["runtime"].mkdir(parents=True, exist_ok=True) + assert BACH_DB == Path(os.environ["BACH_DB"]) + assert reserve._data_roots() == expected, (reserve._data_roots(), expected) + if sys.argv[2] == "probe": + model = "example:small" + digest = "a" * 64 + gib = reserve.GIB + policy = { + "schema": reserve.SCHEMA, "enabled": True, + "memory_reserve_bytes": gib, "disk_reserve_bytes": gib, + "wait_seconds": 2, "poll_seconds": 1, + "models": {model: { + "digest": digest, "weight_budget_bytes": 2 * gib, + "kv_headroom_bytes": gib // 2, + "overhead_bytes": gib // 4, "num_ctx": 1024, + }}, + } + path = reserve.policy_path() + path.parent.mkdir(parents=True) + path.write_text(json.dumps(policy), encoding="utf-8") + + async def fake_metadata(*args): + return { + "external": False, "digest": digest, + "weight_floor_bytes": 2 * gib, "resident_context": None, + } + + reserve.observe_ollama = fake_metadata + reserve.psutil.virtual_memory = lambda: SimpleNamespace( + total=32 * gib, available=8 * gib + ) + checked_roots = [] + + def fake_disk_usage(path): + checked_roots.append(path) + return SimpleNamespace(free=4 * gib) + + reserve.shutil.disk_usage = fake_disk_usage + receipt = asyncio.run(reserve.probe( + OllamaBackend(default_model=model, num_ctx=1024), model + )) + assert receipt["state"] == "ready" and receipt["admitted"] + assert set(checked_roots) == set(expected.values()) + assert not Path(os.environ["BACH_DB"]).exists() + assert not Path(os.environ["BACH_SLOTS_CONFIG_PATH"]).exists() + print("ROOT_API_RESOURCE_PATHS_OK") + """) + completed = subprocess.run( + [sys.executable, "-c", script, str(source_root), operation], + cwd=tmp_path, env=isolated_env, capture_output=True, text=True, + timeout=30, check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "ROOT_API_RESOURCE_PATHS_OK" in completed.stdout