feat(ledger): coverage self-seeds — enter_project background refresh + refresh_pattern_coverage tool + shape-accounting skill (#2802, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 33s
CI & Build / Build & push image (push) Skipped

The UI Refresh button must not be the only seed path (operator directive,
hit live: the P7 backfill stalled waiting for a click). Three parts:

- enter_project fire-and-forgets refresh_if_stale on the project OWNER —
  absent or day-old readouts recompute in the background (same spawn the
  webhook path uses), the enter stays fast, forge-less owners exit quietly
  (rule #115 baseline), and an in-flight guard keeps concurrent enters from
  fetching the same tarball N times.
- refresh_pattern_coverage(project_id): the synchronous agent-facing form —
  write-gated, owner-keyring resolution, and ValueError messages that name
  the fix (add a connection / bind_repo) instead of measuring nothing
  silently.
- plugin 0.1.33 ships the shape-accounting skill: the five statuses, the
  seed/todo/judge loop, and the derive-first rule, triggered by the
  coverage line or any proved code-to-canon relationship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 08:18:36 -04:00
co-authored by Claude Fable 5
parent 9f1a52a035
commit e2a084f1fb
8 changed files with 262 additions and 5 deletions
+34
View File
@@ -40,6 +40,15 @@ def _no_coverage():
yield
@pytest.fixture(autouse=True)
def _no_background_seed():
"""enter_project now fire-and-forgets a coverage self-seed (#2802). These
are no-database unit tests, so the spawn is stubbed out; the firing shape
has its own test below."""
with patch("scribe.mcp.tools.projects.spawn") as mock:
yield mock
@pytest.fixture(autouse=True)
def _no_bootstrap():
"""With _no_systems stubbing an empty vocabulary, every test here reaches
@@ -319,6 +328,31 @@ async def test_enter_project_never_asks_bootstrap_once_a_vocabulary_exists(
))
out = await enter_project(project_id=5)
assert "systems_bootstrap" not in out
@pytest.mark.asyncio
async def test_enter_project_fires_the_coverage_seed_on_the_owner(
_no_background_seed,
):
"""The arrival self-seed (#2802): entering spawns refresh_if_stale on the
project OWNER's id with the cache read the enter already did — fire and
forget, so a missing ledger seeds itself without the UI button, and the
enter stays fast."""
import contextlib
project = _fake_project(id=5)
project.user_id = 42 # explicit: the OWNER, not the caller (ctx uid=7)
with contextlib.ExitStack() as stack:
for cm in _enter_project_stubs(project):
stack.enter_context(cm)
seed = stack.enter_context(patch(
"scribe.mcp.tools.projects.coverage_svc.refresh_if_stale",
MagicMock(return_value=object()),
))
await enter_project(project_id=5)
seed.assert_called_once_with(42, 5, cached=None)
_no_background_seed.assert_called_once()
assert _no_background_seed.call_args.kwargs["site"] == "enter_project.coverage_seed"
_no_bootstrap.assert_not_awaited()
+46
View File
@@ -390,6 +390,52 @@ async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
_user_id_ctx.reset(token)
@pytest.mark.integration
async def test_explicit_refresh_names_its_failures(seeded):
"""refresh_for_caller (#2802) raises fixable errors instead of silence:
an agent mid-task must learn WHY nothing measured — 'None' is exactly the
stranding the button-only path caused."""
from scribe.models import async_session
from scribe.models.user import User
from scribe.services.coverage import refresh_for_caller
from sqlalchemy import select
uid, pid = seeded["uid"], seeded["pid"]
# The owner has no forge connection rows → the error names the fix.
with pytest.raises(ValueError) as err:
await refresh_for_caller(uid, pid)
assert "Git Forges" in str(err.value)
# A stranger gets not-found/no-write, never a measurement.
async with async_session() as s:
other = (await s.execute(
select(User).where(User.username == "coverage_outsider")
)).scalar_one_or_none()
if other is None:
other = User(username="coverage_outsider")
s.add(other)
await s.flush()
other_id = other.id
await s.commit()
with pytest.raises(ValueError) as err:
await refresh_for_caller(other_id, pid)
assert "no write access" in str(err.value)
@pytest.mark.integration
async def test_background_seed_is_quiet_without_a_forge(seeded):
"""refresh_if_stale (#2802) must exit silently for a forge-less owner —
rule #115's baseline — and treat a fresh cache as nothing-to-do."""
from scribe.services.coverage import refresh_coverage, refresh_if_stale
uid, pid = seeded["uid"], seeded["pid"]
# Absent cache + no forge rows: returns without raising, writes nothing.
await refresh_if_stale(uid, pid)
# Fresh cache: returns before ever consulting the keyring.
stored = await refresh_coverage(uid, pid, selector=_selector(_tarball(TREE)))
await refresh_if_stale(uid, pid, cached=stored)
@pytest.mark.integration
async def test_unservable_binding_measures_nothing(seeded):
"""A project bound only to a host the forge doesn't serve returns None —
+1 -1
View File
@@ -80,5 +80,5 @@ def test_classify_and_list_are_mounted_as_mcp_tools():
from scribe.mcp.server import build_mcp_server
mcp = build_mcp_server()
for name in ("classify_shapes", "list_shapes"):
for name in ("classify_shapes", "list_shapes", "refresh_pattern_coverage"):
assert mcp._tool_manager.get_tool(name) is not None