Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 16 additions & 2 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
82 changes: 82 additions & 0 deletions tests/test_serve_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_sidecar_change_invalidates_project_context_too()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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
Loading