From 9e91f6cb930b56d487e21c613bc0375c049c54c0 Mon Sep 17 00:00:00 2001 From: Azeem Date: Tue, 25 Aug 2026 01:17:26 +0000 Subject: [PATCH] fix(serve): include the learning sidecar in the graph-context cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context cache keyed on graph.json's (mtime_ns, size) alone, but the .graphify_learning.json sidecar is baked into the cached graph object at load time (_load_graph attaches it as the learning= annotation overlay). graphify reflect rewrites only the sidecar, never graph.json — so a long-running MCP server kept serving stale lesson annotations (or none) until the graph itself happened to change. The key now appends the sidecar's (mtime_ns, size), with (0, 0) standing in when no sidecar exists: appearing, changing, and vanishing each invalidate exactly like a graph change, on both the pinned default graph and project_path LRU contexts; an unchanged pair still hits the cache. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++ graphify/serve.py | 18 ++++++++- tests/test_serve_http.py | 82 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c82baccf9e..4223d8ac72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: the MCP server's graph-context cache now fingerprints the learning sidecar, so a `graphify reflect` run shows up in a running server's answers. The cache keyed each context on graph.json's (mtime_ns, size) alone, but the `.graphify_learning.json` sidecar is baked into the cached graph object at load time (it becomes the `learning=` annotations) — and reflect rewrites only the sidecar, never graph.json, so a long-running server kept serving the old lessons (or none) until the graph itself changed. The sidecar's (mtime_ns, size) — (0, 0) when absent — is now part of the cache key for both the pinned default graph and `project_path` contexts: a sidecar appearing, changing, or vanishing invalidates exactly like a graph change, while an unchanged pair still hits the cache. + ## 0.9.50 (2026-08-25) - Fix: Ruby methods whose names end in `!`, `?`, or `=` now keep distinct node ids, so `save` and `save!` (or `foo` and `foo=`) no longer collide into one node; the label keeps the raw spelling and member-call resolution still matches (#3077, thanks @hopstreax). diff --git a/graphify/serve.py b/graphify/serve.py index 58c0925b4e..64f08c7bd1 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -15,6 +15,7 @@ from graphify.security import sanitize_label, check_graph_file_size_cap from graphify.build import edge_data, edge_datas from graphify.paths import default_graph_json as _default_graph_json +from graphify.reflect import LEARNING_SIDECAR_NAME try: import jieba as _jieba # type: ignore[import-untyped] @@ -108,7 +109,7 @@ def __init__(self, max_contexts: int): self._pinned: dict[str, dict] = {} self._lock = threading.Lock() - def _load_entry(self, resolved_path: str, key: tuple[int, int]) -> dict: + def _load_entry(self, resolved_path: str, key: tuple[int, ...]) -> dict: """Build one entry for an already-resolved path and known file key. ``_load_graph`` is also used by the CLI, where invalid input terminates @@ -144,7 +145,20 @@ def load(self, resolved_path: str, *, pinned: bool = False) -> tuple[nx.Graph, d stat_result = Path(resolved_path).stat() except FileNotFoundError: raise FileNotFoundError(f"graph.json not found: {resolved_path}") from None - key = (stat_result.st_mtime_ns, stat_result.st_size) + # The learning sidecar is baked into the cached graph object at + # load time (_load_graph attaches it as the learning= annotation + # overlay), so it is part of what the key must fingerprint: a + # sidecar-only change — a `graphify reflect` run touches only + # .graphify_learning.json, never graph.json — must miss the cache + # exactly like a graph change, or a running server keeps serving + # the stale lesson annotations. (0, 0) stands in when no sidecar + # exists, so one appearing or vanishing changes the key too. + try: + sidecar_stat = (Path(resolved_path).parent / LEARNING_SIDECAR_NAME).stat() + sidecar_key = (sidecar_stat.st_mtime_ns, sidecar_stat.st_size) + except OSError: + sidecar_key = (0, 0) + key = (stat_result.st_mtime_ns, stat_result.st_size, *sidecar_key) entries = self._pinned if pinned else self._entries entry = entries.get(resolved_path) if entry is not None and entry["key"] == key: diff --git a/tests/test_serve_http.py b/tests/test_serve_http.py index 7893a0f4e3..ddcfc0e3d0 100644 --- a/tests/test_serve_http.py +++ b/tests/test_serve_http.py @@ -361,3 +361,85 @@ def test_cli_api_key_from_env(monkeypatch): monkeypatch.setattr(serve_mod, "serve_http", lambda gp, **k: captured.update(**k)) serve_mod._main(["g.json", "--transport", "http"]) assert captured["api_key"] == "from-env" + + +# --- cache key: a sidecar-only change must invalidate the graph context -------- + + +def test_sidecar_only_change_invalidates_cached_context(tmp_path, monkeypatch): + """A `graphify reflect` run rewrites only .graphify_learning.json, never + graph.json. The context cache used to key on graph.json's (mtime_ns, size) + alone, so a running server kept serving lesson annotations from the old + sidecar until the graph itself changed. The sidecar's (mtime_ns, size) — + (0, 0) when absent — is now part of the key.""" + graph_file = _graph_file(tmp_path) + sidecar = tmp_path / ".graphify_learning.json" + + original_load = serve_mod._load_graph + loads = {"n": 0} + + def counting_load(path: str): + loads["n"] += 1 + return original_load(path) + + monkeypatch.setattr(serve_mod, "_load_graph", counting_load) + + app = serve_mod._build_http_app(graph_file, json_response=True) + with _client(app) as client: + headers = _init_session(client) + # 1. No sidecar: un-annotated answer. (The server start already loaded + # the default graph once.) + out = _call_tool(client, headers, "query_graph", {"question": "Alpha"}, rid=2) + assert "learning=" not in out + assert loads["n"] == 1 + + # 2. Sidecar appears, graph.json untouched: the same running server + # must reload and annotate. + sidecar.write_text( + json.dumps({"version": 1, "nodes": {"a": {"status": "preferred"}}}), + encoding="utf-8", + ) + out = _call_tool(client, headers, "query_graph", {"question": "Alpha"}, rid=3) + assert "learning=preferred" in out + assert loads["n"] == 2 + + # 3. Nothing changed: still a cache hit. + out = _call_tool(client, headers, "query_graph", {"question": "Alpha"}, rid=4) + assert "learning=preferred" in out + assert loads["n"] == 2 + + # 4. Sidecar rewritten (a fresh reflect run): reload, new verdict. + sidecar.write_text( + json.dumps({"version": 1, "nodes": {"a": {"status": "contested"}}}), + encoding="utf-8", + ) + out = _call_tool(client, headers, "query_graph", {"question": "Alpha"}, rid=5) + assert "learning=contested" in out + assert loads["n"] == 3 + + # 5. Sidecar vanishes: reload again, annotations gone. + sidecar.unlink() + out = _call_tool(client, headers, "query_graph", {"question": "Alpha"}, rid=6) + assert "learning=" not in out + assert loads["n"] == 4 + + +def test_sidecar_change_invalidates_project_context_too(tmp_path, monkeypatch): + """Same contract on the LRU (project_path) side of the cache.""" + proj = _project_with_graph(tmp_path, node_count=3) + default_graph = _graph_file(tmp_path) + sidecar = Path(proj) / "graphify-out" / ".graphify_learning.json" + + app = serve_mod._build_http_app(default_graph, json_response=True) + with _client(app) as client: + headers = _init_session(client) + out = _call_tool(client, headers, "query_graph", + {"question": "N1", "project_path": proj}, rid=2) + assert "learning=" not in out + sidecar.write_text( + json.dumps({"version": 1, "nodes": {"n1": {"status": "preferred"}}}), + encoding="utf-8", + ) + out = _call_tool(client, headers, "query_graph", + {"question": "N1", "project_path": proj}, rid=3) + assert "learning=preferred" in out