CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 33s
CI & Build / Python tests (push) Failing after 37s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Build & push image (push) Skipped
`when_to_apply` is not metadata. `rule_document` embeds a rule as
`{title} — {trigger}` / `When to apply: {trigger}\n\n{statement}`, the trigger
appearing twice so purpose dominates a short vector — the shape note 2485
measured on snippets (a 0.153 top-to-second gap against 0.010–0.023 for
everything else). Without one the document silently becomes title + statement:
a DIFFERENT shape, ranked against a corpus it does not match, with nothing to
report it. Every bar and every rank in the system assumes one shape.
`create_preference` has refused an empty trigger since it shipped. The two rule
creators defaulted it to "" — so the shape was enforced for the record kind
that guides and optional for the kind that binds.
The guard lives in the SERVICE, because both doors reach it: the MCP tools and
the frontend's fast path in routes/rulebooks.py. Written in either alone, the
other could still create a rule that never fires. The route keeps a matching
check for the STATUS CODE only (400, not the 404 it maps ValueError to).
update_rule refuses to EMPTY an existing trigger, checked after the mutation so
it covers `clear=[...]`, an emptied form input, and any route added later.
Deliberately asked as "did this edit remove one" rather than "does one exist":
a rule predating the guard has none, and refusing to save it would freeze
precisely the unreachable records that most need fixing.
Deliberately not following arose_from_id, which the human door exempts itself
from because provenance is about auditing what the AGENT changed. That reasoning
does not reach this field — a missing trigger is not a missing explanation, it
is a rule that does not work, and it fails an operator as badly as a session.
15 test fixtures across 6 files were creating rules with no trigger. They now
pass one; that they did not is the point — curation is not a guarantee.
Step 1 of milestone 416 "Retrieval stops guessing a bar". First because every
later step assumes one document shape, and it is much cheaper to guarantee
before a corpus grows than to backfill after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
175 lines
7.2 KiB
Python
175 lines
7.2 KiB
Python
"""#3262 against a real Postgres: the embedder loses the race, it doesn't run it.
|
|
|
|
The reported failure was a deadlock — `DELETE FROM rulebooks` killed by the
|
|
server while a detached `upsert_rule_embedding` held the other half of the
|
|
cycle. It cannot be reproduced with mocks, because there is nothing to
|
|
deadlock: the whole bug lives in the ORDER two transactions take two row
|
|
locks, which only a lock manager can adjudicate.
|
|
|
|
So each test here holds a real delete open in one transaction and calls the
|
|
embedder in another. What is being pinned is that the embedder RETURNS —
|
|
promptly, having written nothing. Before the fix it would sit on the chunk
|
|
rows waiting for a delete that is itself waiting on the insert's foreign key,
|
|
and the test would hang rather than fail, which is why every call carries a
|
|
deadline (rule 156).
|
|
"""
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import delete, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
|
|
from scribe.models.note import Note
|
|
from scribe.models.rulebook import Rulebook
|
|
from scribe.services import embeddings as emb
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
from tests.helpers import ensure_user
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
|
|
|
OWNER_USERNAME = "embed_lock_owner"
|
|
|
|
# Generous, because the assertion is "it did not block indefinitely", not "it
|
|
# was fast". A machine under load must not turn this into a flake; a genuinely
|
|
# blocked embedder never returns at all, so no honest run comes near this.
|
|
YIELD_DEADLINE_SECONDS = 20
|
|
|
|
# What the embedder would write if it wrongly went ahead. Distinct from the
|
|
# text the fixture's own create_* wrote, so the assertion cannot be satisfied
|
|
# by rows that were already there.
|
|
SENTINEL = "sentinelvector"
|
|
|
|
ONE_VECTOR = [[0.0] * 384]
|
|
|
|
# The fixture's own embedding task runs the REAL embedder, which either loads a
|
|
# model or gives up; both are bounded well inside this.
|
|
SETTLE_DEADLINE_SECONDS = 30
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def seeded():
|
|
"""A rule and a note to race against.
|
|
|
|
CLEANED AT SETUP, NOT TEARDOWN — the same constraint #3241 hit and the
|
|
reason this file exists. `create_rule` fires its own detached embedding
|
|
task; a teardown that deleted the rulebook would be racing exactly the
|
|
thing under test, on a loop that is closing.
|
|
"""
|
|
async with async_session() as s:
|
|
owner = await ensure_user(s, OWNER_USERNAME)
|
|
uid = owner.id
|
|
await s.commit()
|
|
for book in (await s.execute(
|
|
select(Rulebook).where(Rulebook.owner_user_id == uid)
|
|
)).scalars().all():
|
|
await s.delete(book)
|
|
for note in (await s.execute(
|
|
select(Note).where(Note.user_id == uid)
|
|
)).scalars().all():
|
|
await s.delete(note)
|
|
await s.commit()
|
|
|
|
book = await rulebooks_svc.create_rulebook(uid, "Lock fixtures")
|
|
topic = await rulebooks_svc.create_topic(book.id, uid, "locks")
|
|
rule = await rulebooks_svc.create_rule(
|
|
when_to_apply="when the moment this fixture stands in for arises",
|
|
topic.id, uid, "A rule with vectors",
|
|
"Something for the embedder to index.",
|
|
)
|
|
async with async_session() as s:
|
|
note = Note(user_id=uid, title="A note with vectors", body="Body text.")
|
|
s.add(note)
|
|
await s.commit()
|
|
note_id = note.id
|
|
|
|
await _settle_detached_writes()
|
|
return {"uid": uid, "book_id": book.id, "rule_id": rule.id, "note_id": note_id}
|
|
|
|
|
|
async def _settle_detached_writes() -> None:
|
|
"""Let `create_rule`'s own fire-and-forget embedding task finish.
|
|
|
|
It is the same detached write these tests are about, aimed at the same
|
|
rule, and left in flight it would land in the middle of an assertion about
|
|
that rule's rows. Bounded, and a timeout is not a failure — the tests below
|
|
carry their own deadlines, and this is only tidying the start line.
|
|
"""
|
|
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
if pending:
|
|
await asyncio.wait(pending, timeout=SETTLE_DEADLINE_SECONDS)
|
|
|
|
|
|
async def _rule_chunks(rule_id: int) -> list[str]:
|
|
async with async_session() as s:
|
|
return list((await s.execute(
|
|
select(RuleEmbedding.chunk_text).where(RuleEmbedding.rule_id == rule_id)
|
|
)).scalars().all())
|
|
|
|
|
|
async def _note_chunks(note_id: int) -> list[str]:
|
|
async with async_session() as s:
|
|
return list((await s.execute(
|
|
select(NoteEmbedding.chunk_text).where(NoteEmbedding.note_id == note_id)
|
|
)).scalars().all())
|
|
|
|
|
|
async def test_a_rule_refresh_yields_to_a_delete_cascading_from_its_rulebook(seeded):
|
|
"""The reported case, exactly: the delete lands on the RULEBOOK and reaches
|
|
the rule through two cascades, which is why nothing on the rule's own write
|
|
path could have seen it coming."""
|
|
async with async_session() as blocker:
|
|
# Uncommitted on purpose — the cascade's locks are held for as long as
|
|
# this transaction stays open, which is the state the embedder must
|
|
# decline to fight over.
|
|
await blocker.execute(delete(Rulebook).where(Rulebook.id == seeded["book_id"]))
|
|
try:
|
|
with patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)):
|
|
await asyncio.wait_for(
|
|
emb.upsert_rule_embedding(
|
|
seeded["rule_id"], SENTINEL, f"{SENTINEL} statement",
|
|
),
|
|
timeout=YIELD_DEADLINE_SECONDS,
|
|
)
|
|
finally:
|
|
await blocker.rollback()
|
|
|
|
assert not any(SENTINEL in text for text in await _rule_chunks(seeded["rule_id"])), \
|
|
"the embedder wrote into a rule that was being deleted"
|
|
|
|
|
|
async def test_a_note_refresh_yields_to_a_delete_of_the_note(seeded):
|
|
"""The note twin, which #3262 recorded as unverified. Notes are soft-deleted
|
|
day to day, so the hard delete a trash purge issues is the one that can put
|
|
a lock on the row while a refresh is in flight."""
|
|
async with async_session() as blocker:
|
|
await blocker.execute(delete(Note).where(Note.id == seeded["note_id"]))
|
|
try:
|
|
with patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)):
|
|
await asyncio.wait_for(
|
|
emb.upsert_note_embedding(
|
|
seeded["note_id"], seeded["uid"], SENTINEL, f"{SENTINEL} body",
|
|
),
|
|
timeout=YIELD_DEADLINE_SECONDS,
|
|
)
|
|
finally:
|
|
await blocker.rollback()
|
|
|
|
assert not any(SENTINEL in text for text in await _note_chunks(seeded["note_id"])), \
|
|
"the embedder wrote into a note that was being deleted"
|
|
|
|
|
|
async def test_an_uncontended_refresh_still_writes(seeded):
|
|
"""The guard against the cheapest possible false pass: a claim that never
|
|
succeeds would satisfy both tests above while quietly ending semantic
|
|
search."""
|
|
with patch.object(emb, "get_embeddings", AsyncMock(return_value=ONE_VECTOR)):
|
|
await emb.upsert_rule_embedding(
|
|
seeded["rule_id"], SENTINEL, f"{SENTINEL} statement",
|
|
)
|
|
|
|
assert any(SENTINEL in text for text in await _rule_chunks(seeded["rule_id"])), \
|
|
"an unlocked rule was not embedded"
|