fix(6872): a verdict or write-path stamp on a vanished shape revives its row instead of inserting a duplicate
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / integration (push) Successful in 1m42s
CI & Build / Build & push image (push) Successful in 18s

classify_shapes(repo=...) and the write-path hook looked only at live rows,
so a shape whose row had vanished got a second provisional row under the
same (project, repo, path, symbol, kind). The unique constraint refused it,
and the whole classify batch was lost as a tool error.

provisional_row() now finds the row under that identity, vanished or not.
A vanished row is revived with a reappeared event and keeps its judgment.
A new row is inserted inside a savepoint, so a concurrent insert of the
same identity falls back to that row instead of failing the transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 12:02:13 -05:00
co-authored by Claude Opus 5.5
parent 219bfeac42
commit 6be44013bb
2 changed files with 121 additions and 7 deletions
+46 -7
View File
@@ -258,6 +258,46 @@ def _event(row: CodeShape, event: str, at: datetime, *, commit: str = "") -> Cod
) )
async def provisional_row(
session, *, project_id: int, repo_key: str, path: str, symbol: str, kind: str,
) -> CodeShape:
"""The row for a shape a caller met before any live row for it existed:
a shape being written this turn, or one judged ahead of the sync.
The identity (project, repo, path, symbol, kind) is unique, so "no live
row" does not mean "no row". A VANISHED row under the identity is revived
and keeps its standing judgment, as a sync revives a shape that comes
back. The next sync then confirms it, or stamps it vanished again until
the code reaches the bound ref. With no row at all, a provisional one is
inserted inside a savepoint. If a concurrent request inserts the same
identity first, the savepoint rolls back and that request's row is used.
The caller's transaction survives either way.
"""
from sqlalchemy.exc import IntegrityError
def _same():
return select(CodeShape).where(
CodeShape.project_id == project_id, CodeShape.repo_key == repo_key,
CodeShape.path == path, CodeShape.symbol == symbol, CodeShape.kind == kind,
)
row = (await session.execute(_same())).scalars().first()
if row is None:
try:
async with session.begin_nested():
row = CodeShape(project_id=project_id, repo_key=repo_key,
path=path, symbol=symbol, kind=kind)
session.add(row)
await session.flush()
return row
except IntegrityError:
row = (await session.execute(_same())).scalars().one()
if row.vanished_at is not None:
row.vanished_at = None
session.add(_event(row, "reappeared", datetime.now(timezone.utc)))
return row
async def _judge( async def _judge(
session, row: CodeShape, *, status: str, snippet_id: int | None, session, row: CodeShape, *, status: str, snippet_id: int | None,
by: str | None, reason: str | None, at: datetime, reason_code: str | None = None, by: str | None, reason: str | None, at: datetime, reason_code: str | None = None,
@@ -726,11 +766,10 @@ async def classify_shapes(
if not matches and repo_key and item.get("status") != "unclassified": if not matches and repo_key and item.get("status") != "unclassified":
path = (item.get("path") or "").strip() path = (item.get("path") or "").strip()
symbol = (item.get("symbol") or "").strip() symbol = (item.get("symbol") or "").strip()
row = CodeShape( row = await provisional_row(
project_id=project_id, repo_key=repo_key, path=path, session, project_id=project_id, repo_key=repo_key, path=path,
symbol=symbol, kind=kind or snippet_kind(symbol, ""), symbol=symbol, kind=kind or snippet_kind(symbol, ""),
) )
session.add(row)
by_key.setdefault((path, symbol), []).append(row) by_key.setdefault((path, symbol), []).append(row)
matches = [row] matches = [row]
provisional += 1 provisional += 1
@@ -1655,13 +1694,13 @@ async def suggest_write_path_instances(
if row is None: if row is None:
if not repo_key: if not repo_key:
continue continue
row = CodeShape( # It may be a vanished row coming back, with its judgment.
project_id=project_id, repo_key=repo_key, row = await provisional_row(
session, project_id=project_id, repo_key=repo_key,
path=path, symbol=name, kind=kind, path=path, symbol=name, kind=kind,
) )
session.add(row)
by_key[(name, kind)] = row by_key[(name, kind)] = row
elif not (row.status in _MECHANICAL_TODO or row.classified_by == "hook"): if not (row.status in _MECHANICAL_TODO or row.classified_by == "hook"):
continue # a judgment — or the canon itself — stands continue # a judgment — or the canon itself — stands
# A proposal, never a status: the writer decides. # A proposal, never a status: the writer decides.
row.proposed_snippet_id = sid row.proposed_snippet_id = sid
+75
View File
@@ -1252,6 +1252,81 @@ async def test_a_shape_written_this_turn_is_judged_before_the_sync_sees_it(seede
assert row.vanished_at is None and row.last_seen_commit == "c2" assert row.vanished_at is None and row.last_seen_commit == "c2"
async def _rows_at(pid: int, path: str) -> list[CodeShape]:
"""Every row at ``path``, vanished ones included: what the identity
constraint sees."""
async with async_session() as s:
return list((await s.execute(select(CodeShape).where(
CodeShape.project_id == pid, CodeShape.path == path,
))).scalars().all())
@pytest.mark.integration
async def test_judging_a_vanished_shape_revives_its_row(seeded):
"""A verdict with `repo` on a shape whose row has vanished lands on that
row and brings it back. It does not insert a second row under the same
identity, which the database refuses, taking the whole batch with it."""
from grimoire.services import repo_bindings as repo_bindings_svc
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await repo_bindings_svc.set_binding(owner, REPO, pid)
await sync_repo_shapes(
pid, REPO, [s for s in SHAPES if s[0] != "src/util.py"], seen_marker="c1",
)
[gone] = await _rows_at(pid, "src/util.py")
assert gone.vanished_at is not None
out = await classify_shapes(owner, pid, [
{"path": "src/util.py", "symbol": "helper", "kind": "sym", "status": "exempt",
"reason": "the module's own parse step", "reason_code": "pure-helper"},
{"path": "src/app.py", "symbol": "make_app", "status": "instance", "snippet_id": sid},
], repo=REPO)
assert out["classified"] == 2 and out["unmatched"] == []
[row] = await _rows_at(pid, "src/util.py")
assert row.id == gone.id and row.vanished_at is None
assert (row.status, row.reason_code) == ("exempt", "pure-helper")
@pytest.mark.integration
async def test_a_write_path_stamp_on_a_vanished_shape_revives_its_row(seeded):
"""The write-path hook meets a shape being written again after a sync
stamped it vanished. Its suggestion lands on the existing row, with no
second insert, and a standing verdict on that row still stands."""
from datetime import datetime, timezone
from grimoire.services.shape_ledger import suggest_write_path_instances
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
pulled = {sid: datetime.now(timezone.utc)}
code = "def helper():\n return factory()\n"
await sync_repo_shapes(
pid, REPO, [s for s in SHAPES if s[0] != "src/util.py"], seen_marker="c1",
)
suggested = await suggest_write_path_instances(
owner, pid, path="src/util.py", shapes=[("sym", "helper")],
code=code, pulled=pulled, repo_key=REPO,
)
assert [s["symbol"] for s in suggested] == ["helper"]
[row] = await _rows_at(pid, "src/util.py")
assert row.vanished_at is None and row.proposed_snippet_id == sid
# A verdict on the row, then another vanish: the next stamp leaves it be.
await classify_shapes(owner, pid, [
{"path": "src/util.py", "symbol": "helper", "status": "exempt", "reason": "local"},
])
await sync_repo_shapes(
pid, REPO, [s for s in SHAPES if s[0] != "src/util.py"], seen_marker="c2",
)
assert await suggest_write_path_instances(
owner, pid, path="src/util.py", shapes=[("sym", "helper")],
code=code, pulled=pulled, repo_key=REPO,
) == []
[row] = await _rows_at(pid, "src/util.py")
assert row.status == "exempt" and row.classified_by == "agent"
@pytest.mark.integration @pytest.mark.integration
async def test_unjudged_is_what_nobody_read(seeded): async def test_unjudged_is_what_nobody_read(seeded):
"""The end-of-turn question: a shape with no row, an unclassified one, a """The end-of-turn question: a shape with no row, an unclassified one, a