fix(telemetry): usage readout grouped by a rebuilt CASE — group by the label instead (#2663 root cause)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 39s
CI's new integration tests reproduced the outage on a clean database and named the half: the writes land fine, and usage_for_notes fails on EVERY call. The GROUP BY rebuilt the ambient case() expression, and asyncpg's expanding IN-parameters give each instance its own bind names — so Postgres sees a SELECT expression the GROUP BY doesn't cover and rejects the query with a GroupingError, which the old code swallowed into zeros. One labelled expression, grouped by its label. The deployed table has been accumulating events all along; history appears as soon as this deploys. Also: the two hook-execution tests now run with the real PATH and skip when the hook's tools are absent (the restricted-PATH convention next door is for silence contracts, where empty-for-the-wrong-reason still passes) — and the unit lane installs jq so 'skip' never quietly becomes 'nowhere'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -225,6 +225,16 @@ jobs:
|
|||||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||||
run: uv sync --locked --extra dev
|
run: uv sync --locked --extra dev
|
||||||
|
|
||||||
|
# The hook-EXECUTION tests (test_write_path_trigger's nudge pair) run the
|
||||||
|
# real bash hook, which exits silently without jq — and those tests skip
|
||||||
|
# rather than fail when it's absent, so without this step they would
|
||||||
|
# quietly never be verified anywhere (ci-python ships without jq; same
|
||||||
|
# install the Plugin hooks job does).
|
||||||
|
- name: Install jq for hook execution tests
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends jq
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
# Integration tests (real Postgres) run in the `integration` job below.
|
# Integration tests (real Postgres) run in the `integration` job below.
|
||||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||||
|
|||||||
@@ -186,6 +186,18 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
|
|||||||
if not ids:
|
if not ids:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
# Classified in SQL so the group count stays small: per note we get at most
|
||||||
|
# (surfaced-ranked, surfaced-ambient, pulled) rather than one row per
|
||||||
|
# distinct source. ONE labelled expression, grouped by its label — a second
|
||||||
|
# case() instance in GROUP BY renders with its own expanding-IN bind names
|
||||||
|
# under asyncpg, so the database sees two DIFFERENT expressions and rejects
|
||||||
|
# the query with a GroupingError. That rejection was swallowed, which is
|
||||||
|
# how every counter read zero in production while the writes were landing
|
||||||
|
# fine (#2663).
|
||||||
|
ambient = case(
|
||||||
|
(NoteUsageEvent.source.in_(AMBIENT_SOURCES), True),
|
||||||
|
else_=False,
|
||||||
|
).label("ambient")
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
@@ -195,22 +207,13 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
|
|||||||
NoteUsageEvent.event,
|
NoteUsageEvent.event,
|
||||||
func.count().label("n"),
|
func.count().label("n"),
|
||||||
func.max(NoteUsageEvent.created_at).label("last_at"),
|
func.max(NoteUsageEvent.created_at).label("last_at"),
|
||||||
# Classified in SQL so the group count stays small: per
|
ambient,
|
||||||
# note we get at most (surfaced-ranked, surfaced-ambient,
|
|
||||||
# pulled) rather than one row per distinct source.
|
|
||||||
case(
|
|
||||||
(NoteUsageEvent.source.in_(AMBIENT_SOURCES), True),
|
|
||||||
else_=False,
|
|
||||||
).label("ambient"),
|
|
||||||
)
|
)
|
||||||
.where(NoteUsageEvent.note_id.in_(ids))
|
.where(NoteUsageEvent.note_id.in_(ids))
|
||||||
.group_by(
|
.group_by(
|
||||||
NoteUsageEvent.note_id,
|
NoteUsageEvent.note_id,
|
||||||
NoteUsageEvent.event,
|
NoteUsageEvent.event,
|
||||||
case(
|
ambient,
|
||||||
(NoteUsageEvent.source.in_(AMBIENT_SOURCES), True),
|
|
||||||
else_=False,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
@@ -791,18 +791,39 @@ def test_plugin_version_bumped_with_the_hook():
|
|||||||
assert version >= (0, 1, 18)
|
assert version >= (0, 1, 18)
|
||||||
|
|
||||||
|
|
||||||
|
def _hook_runtime_env():
|
||||||
|
"""Env for tests that need the hook's tools to actually RUN.
|
||||||
|
|
||||||
|
The silence-contract tests above deliberately restrict PATH — the hook must
|
||||||
|
exit quietly when its tools are missing, and asserting on empty output
|
||||||
|
doesn't care why it was empty. These tests assert on CONTENT, so the tools
|
||||||
|
must resolve wherever the image installed them; skip (don't fail) on an
|
||||||
|
image that lacks them, because that image cannot exercise this behaviour
|
||||||
|
at all.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
for tool in ("git", "jq", "curl", "bash"):
|
||||||
|
if shutil.which(tool) is None:
|
||||||
|
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
||||||
|
return {"PATH": os.environ["PATH"],
|
||||||
|
"SCRIBE_URL": "http://127.0.0.1:9", "SCRIBE_TOKEN": "t"}
|
||||||
|
|
||||||
|
|
||||||
def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path):
|
def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path):
|
||||||
"""#2664: the local arm proves duplication; when Scribe has no record of it,
|
"""#2664: the local arm proves duplication; when Scribe has no record of it,
|
||||||
the same context block must ask for create_snippet — the one moment the
|
the same context block must ask for create_snippet — the one moment the
|
||||||
recording nudge is earned rather than noise. An unreachable server counts
|
recording nudge is earned rather than noise. An unreachable server counts
|
||||||
as "nothing recorded": the local finding needed no server, and the nudge
|
as "nothing recorded": the local finding needed no server, and the nudge
|
||||||
fails open with it (here: a refused connection stands in for the instance)."""
|
fails open with it (here: a refused connection stands in for the instance)."""
|
||||||
|
env = _hook_runtime_env()
|
||||||
repo = tmp_path / "repo"
|
repo = tmp_path / "repo"
|
||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||||
(repo / "a.py").write_text("def debounce(fn):\n return fn\n")
|
(repo / "a.py").write_text("def debounce(fn):\n return fn\n")
|
||||||
# git grep searches the index, so the existing copy must be staged.
|
# git grep searches the index, so the existing copy must be staged.
|
||||||
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["bash", str(HOOK)],
|
["bash", str(HOOK)],
|
||||||
input=json.dumps({
|
input=json.dumps({
|
||||||
@@ -810,11 +831,13 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
|
|||||||
"tool_input": {"file_path": str(repo / "b.py"),
|
"tool_input": {"file_path": str(repo / "b.py"),
|
||||||
"content": "def debounce(fn):\n return fn\n"},
|
"content": "def debounce(fn):\n return fn\n"},
|
||||||
}),
|
}),
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True, env=env,
|
||||||
env={"PATH": "/usr/bin:/bin", "SCRIBE_URL": "http://127.0.0.1:9",
|
|
||||||
"SCRIBE_TOKEN": "t"},
|
|
||||||
)
|
)
|
||||||
assert out.returncode == 0
|
assert out.returncode == 0
|
||||||
|
assert out.stdout.strip(), (
|
||||||
|
"hook produced no output — the local arm should have found the "
|
||||||
|
"staged duplicate and nudged"
|
||||||
|
)
|
||||||
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
|
||||||
assert "already defined" in ctx # the duplication finding
|
assert "already defined" in ctx # the duplication finding
|
||||||
assert "create_snippet" in ctx # the recording ask riding it
|
assert "create_snippet" in ctx # the recording ask riding it
|
||||||
@@ -823,9 +846,10 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
|
|||||||
def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path):
|
def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path):
|
||||||
"""A brand-new helper with no other copies earns no nudge — a reflex that
|
"""A brand-new helper with no other copies earns no nudge — a reflex that
|
||||||
fires on every Write is one sessions learn to skip."""
|
fires on every Write is one sessions learn to skip."""
|
||||||
|
env = _hook_runtime_env()
|
||||||
repo = tmp_path / "repo"
|
repo = tmp_path / "repo"
|
||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["bash", str(HOOK)],
|
["bash", str(HOOK)],
|
||||||
input=json.dumps({
|
input=json.dumps({
|
||||||
@@ -833,9 +857,7 @@ def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path):
|
|||||||
"tool_input": {"file_path": str(repo / "b.py"),
|
"tool_input": {"file_path": str(repo / "b.py"),
|
||||||
"content": "def debounce(fn):\n return fn\n"},
|
"content": "def debounce(fn):\n return fn\n"},
|
||||||
}),
|
}),
|
||||||
capture_output=True, text=True,
|
capture_output=True, text=True, env=env,
|
||||||
env={"PATH": "/usr/bin:/bin", "SCRIBE_URL": "http://127.0.0.1:9",
|
|
||||||
"SCRIBE_TOKEN": "t"},
|
|
||||||
)
|
)
|
||||||
assert out.returncode == 0
|
assert out.returncode == 0
|
||||||
assert "create_snippet" not in out.stdout
|
assert "create_snippet" not in out.stdout
|
||||||
|
|||||||
Reference in New Issue
Block a user