feat(snippets): pull-time freshness — the forge confirms the cache at the moment it's trusted (#2690)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Failing after 43s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Failing after 43s
CI & Build / Build & push image (push) Skipped
First consumer of the forge adapter. attach_live_body decorates both pull surfaces (MCP get_snippet, REST detail) with body_source + body_freshness when the instance has a forge: 'current' means the cached code was just found verbatim (whitespace-normalized, the same normalization the verdict hash uses) in the fetched file, and provenance restamps to the file's last commit — reflected in the response and persisted in the background. A snippet body is a FRAGMENT of its file, so a fetch can honestly CONFIRM the cache or report divergence, never clobber the record with the whole file: 'diverged' is the reader's information, and a 404 stamps the mechanically-true 'missing' verdict into the existing attention state — once, not on every pull of an already-flagged record. The probe never raises and never blocks past 2.5s (tighter than the adapter's own timeout — the pull is where a session decides whether pulling is worth it, #2663's finding); a hung forge costs bounded time and the cache serves. A no-forge instance's response stays byte-identical to today's (rule #115 baseline, pinned by test). services/background.py is the new one home for fire-and-forget tasks with strong references (the #2663 GC footgun) — telemetry's two copies predate it and keep their bespoke canaries; new callers use this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""Pull-time freshness (#2690) — attach_live_body against a mocked forge.
|
||||
|
||||
The properties that must hold, each with its own way of rotting:
|
||||
|
||||
- A no-forge instance's pull response is BYTE-IDENTICAL to today's (rule
|
||||
#115 — the baseline, not a degraded mode).
|
||||
- The probe never rewrites the body: "current" refreshes provenance,
|
||||
"diverged" reports, and neither clobbers the record mid-read.
|
||||
- A 404 stamps the mechanically-true 'missing' verdict — once, not on
|
||||
every pull of an already-flagged record.
|
||||
- The pull is never slower than the budget: a hung forge costs bounded
|
||||
time and then the cache serves.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from scribe.services import background
|
||||
from scribe.services import snippets as svc
|
||||
from scribe.services.forge import GiteaForge
|
||||
|
||||
BASE = "https://git.example.com"
|
||||
CODE = "def helper(x):\n return x + 1\n"
|
||||
SHA = "f" * 40
|
||||
|
||||
|
||||
def _note():
|
||||
return SimpleNamespace(id=7, user_id=3, title="", body="", tags=[], data=None)
|
||||
|
||||
|
||||
def _data(*, repo=f"{BASE}/alice/widget", code=CODE, verification=None, provenance=None):
|
||||
snippet = {
|
||||
"code": code,
|
||||
"locations": [{"repo": repo, "path": "src/helper.py", "symbol": "helper"}],
|
||||
}
|
||||
if verification:
|
||||
snippet["verification"] = verification
|
||||
if provenance:
|
||||
snippet["provenance"] = provenance
|
||||
return {"snippet": snippet}
|
||||
|
||||
|
||||
def _forge_with(handler) -> GiteaForge:
|
||||
return GiteaForge(BASE, "tok", transport=httpx.MockTransport(handler))
|
||||
|
||||
|
||||
def _file_response(content: str, commit_sha: str = SHA) -> httpx.Response:
|
||||
return httpx.Response(200, json={
|
||||
"type": "file", "encoding": "base64",
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"last_commit_sha": commit_sha, "path": "src/helper.py",
|
||||
})
|
||||
|
||||
|
||||
def _patched(forge):
|
||||
return patch("scribe.services.forge.get_forge", AsyncMock(return_value=forge))
|
||||
|
||||
|
||||
async def test_no_forge_attaches_nothing():
|
||||
data = _data()
|
||||
before = repr(data)
|
||||
with _patched(None):
|
||||
await svc.attach_live_body(_note(), data)
|
||||
assert repr(data) == before
|
||||
assert "body_source" not in data
|
||||
|
||||
|
||||
async def test_current_code_confirms_and_refreshes_provenance():
|
||||
# The file wraps the cached code with extra context and trailing spaces —
|
||||
# containment is judged after the same normalization the verdict hash uses.
|
||||
file_content = "import os\n\n" + CODE.replace(" + 1", " + 1 ").rstrip() + "\n\n# eof\n"
|
||||
forge = _forge_with(lambda r: _file_response(file_content))
|
||||
saved = {}
|
||||
|
||||
async def fake_update(uid, nid, **fields):
|
||||
saved.update(fields)
|
||||
|
||||
data = _data()
|
||||
with _patched(forge), patch.object(svc.notes_svc, "update_note", fake_update):
|
||||
await svc.attach_live_body(_note(), data)
|
||||
await background.drain()
|
||||
|
||||
assert data["body_source"] == "forge"
|
||||
assert data["body_freshness"] == "current"
|
||||
# Reflected in the response...
|
||||
assert data["snippet"]["provenance"]["commit_sha"] == SHA
|
||||
# ...and persisted, without touching the body.
|
||||
assert saved["data"]["provenance"]["commit_sha"] == SHA
|
||||
assert "body" not in saved
|
||||
|
||||
|
||||
async def test_current_with_same_stored_sha_skips_the_write():
|
||||
forge = _forge_with(lambda r: _file_response("prefix\n" + CODE))
|
||||
update = AsyncMock()
|
||||
data = _data(provenance={"commit_sha": SHA, "fetched_at": "t"})
|
||||
with _patched(forge), patch.object(svc.notes_svc, "update_note", update):
|
||||
await svc.attach_live_body(_note(), data)
|
||||
await background.drain()
|
||||
assert data["body_freshness"] == "current"
|
||||
update.assert_not_called()
|
||||
|
||||
|
||||
async def test_diverged_reports_without_clobbering():
|
||||
forge = _forge_with(lambda r: _file_response("def helper(x):\n return x - 1\n"))
|
||||
update = AsyncMock()
|
||||
data = _data()
|
||||
with _patched(forge), patch.object(svc.notes_svc, "update_note", update):
|
||||
await svc.attach_live_body(_note(), data)
|
||||
await background.drain()
|
||||
assert data["body_source"] == "cache"
|
||||
assert data["body_freshness"] == "diverged"
|
||||
assert data["snippet"]["code"] == CODE
|
||||
update.assert_not_called()
|
||||
|
||||
|
||||
async def test_missing_stamps_the_verdict_once():
|
||||
forge = _forge_with(lambda r: httpx.Response(404, json={}))
|
||||
data = _data()
|
||||
with _patched(forge), patch.object(
|
||||
svc, "record_verification", AsyncMock()
|
||||
) as verdict:
|
||||
await svc.attach_live_body(_note(), data)
|
||||
await background.drain()
|
||||
assert data["body_freshness"] == "missing"
|
||||
verdict.assert_awaited_once()
|
||||
assert verdict.await_args.kwargs["status"] == svc.VERIFY_MISSING
|
||||
|
||||
# Already stamped missing → no re-stamp on the next pull.
|
||||
data2 = _data(verification={"status": svc.VERIFY_MISSING, "code_sha": "x"})
|
||||
with _patched(forge), patch.object(
|
||||
svc, "record_verification", AsyncMock()
|
||||
) as verdict2:
|
||||
await svc.attach_live_body(_note(), data2)
|
||||
await background.drain()
|
||||
assert data2["body_freshness"] == "missing"
|
||||
verdict2.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_unreachable_falls_back_to_cache():
|
||||
def handler(request):
|
||||
raise httpx.ConnectError("down", request=request)
|
||||
|
||||
data = _data()
|
||||
with _patched(_forge_with(handler)):
|
||||
await svc.attach_live_body(_note(), data)
|
||||
assert data["body_source"] == "cache"
|
||||
assert data["body_freshness"] == "unreachable"
|
||||
|
||||
|
||||
async def test_hung_forge_costs_bounded_time(monkeypatch):
|
||||
async def slow_handler(request):
|
||||
await asyncio.sleep(30)
|
||||
return _file_response(CODE)
|
||||
|
||||
monkeypatch.setattr(svc, "PULL_FETCH_BUDGET_S", 0.2)
|
||||
data = _data()
|
||||
start = time.monotonic()
|
||||
with _patched(_forge_with(slow_handler)):
|
||||
await svc.attach_live_body(_note(), data)
|
||||
assert time.monotonic() - start < 2.0
|
||||
assert data["body_freshness"] == "unreachable"
|
||||
|
||||
|
||||
async def test_foreign_repo_and_placeless_records_read_as_cache():
|
||||
forge = GiteaForge(BASE, "tok")
|
||||
data = _data(repo="https://github.com/alice/widget")
|
||||
with _patched(forge):
|
||||
await svc.attach_live_body(_note(), data)
|
||||
assert data["body_freshness"] == "repo-not-on-this-forge"
|
||||
|
||||
placeless = {"snippet": {"code": CODE, "locations": []}}
|
||||
with _patched(forge):
|
||||
await svc.attach_live_body(_note(), placeless)
|
||||
assert placeless["body_freshness"] == "no-recorded-location"
|
||||
|
||||
|
||||
def test_both_pull_surfaces_attach_freshness():
|
||||
"""Source-inspection guard (the CI convention for wiring assertions): the
|
||||
MCP pull and the REST detail route both decorate — a surface that forgets
|
||||
is a surface whose readers silently lose freshness."""
|
||||
import pathlib
|
||||
|
||||
root = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe"
|
||||
mcp_src = (root / "mcp" / "tools" / "snippets.py").read_text()
|
||||
rest_src = (root / "routes" / "snippets.py").read_text()
|
||||
assert "attach_live_body" in mcp_src
|
||||
assert "attach_live_body" in rest_src
|
||||
|
||||
|
||||
async def test_forge_failure_inside_lookup_never_breaks_the_pull():
|
||||
with patch(
|
||||
"scribe.services.forge.get_forge", AsyncMock(side_effect=RuntimeError("cfg"))
|
||||
):
|
||||
data = _data()
|
||||
await svc.attach_live_body(_note(), data)
|
||||
assert "body_source" not in data
|
||||
Reference in New Issue
Block a user