Review decisions outrank the queue; stop waiting on OUR name for a heading

Two bugs behind one failure. Dungeons & Dragons timed out waiting for a
dialog heading matching our stored name — but BGG 140509's primary name
is "Dragones Y Mazmorras"; we matched it through an ALTERNATE name, so
that heading never appears. The add flow now waits for the Own checkbox
(the form itself) instead: /boardgame/<id>/ already establishes which
game the page is.

And the job should not have run at all. to_add.csv is a snapshot from
the last diff, so any review decision taken afterwards — local,
rejected, wrong-match — was invisible to upload. run_upload now
cross-checks every queued job against the CURRENT matches.csv and skips
those it no longer endorses, naming each and pointing at diff. When
matches.csv is absent or empty it condemns nothing: absence is not a
verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 23:02:59 -04:00
co-authored by Claude Fable 5
parent 6e72ef22d1
commit 99c1fbf02d
3 changed files with 111 additions and 5 deletions
+47 -5
View File
@@ -39,7 +39,8 @@ import typer
from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_text
from bggpipe.models import CollectionItem
from bggpipe.models import CollectionItem, is_recognized
from bggpipe.resolve import read_matches
BGG = "https://boardgamegeek.com"
UPLOAD_LOG_COLUMNS = [
@@ -89,6 +90,33 @@ def _job_key(row: dict) -> tuple[str, str, str]:
return _key(row["action"], row["bgg_id"], row["collid"], row["version_id"])
def stale_jobs(queue_rows: list[dict], match_rows: list[dict]) -> dict[str, str]:
"""bgg_id -> why, for queued games the CURRENT matches.csv no longer
endorses. to_add.csv is a snapshot from the last diff; a review
decision taken afterwards (marking a game local, rejecting it, calling
a match wrong) must not still upload."""
if not match_rows:
# nothing to compare against (no matches.csv, or a test harness
# driving the queue directly): absence is not a verdict
return {}
live: dict[str, list[dict]] = {}
for row in match_rows:
if row.get("bgg_id"):
live.setdefault(row["bgg_id"], []).append(row)
stale = {}
for row in queue_rows:
bgg_id = row.get("bgg_id") or ""
rows = live.get(bgg_id, [])
if any(is_recognized(r) for r in rows):
continue
if not rows:
stale[bgg_id] = "no longer matched to this game in matches.csv"
else:
statuses = sorted({r["match_status"] for r in rows})
stale[bgg_id] = f"now {', '.join(statuses)} in matches.csv"
return stale
def outstanding_failures(log_rows: list[dict]) -> int:
"""Jobs whose LATEST attempt failed. upload_log.csv is an append-only
audit trail, so counting every 'failed' row ever written would keep
@@ -515,10 +543,12 @@ class PlaywrightUploader:
self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
add_btn = page.get_by_role("button", name="Add To").first
dialog = self._open_dialog(add_btn)
# Content settles when the game-name heading replaces "Loading...".
dialog.get_by_role(
"heading", name=re.compile(re.escape(job.name), re.I)
).wait_for(timeout=15_000)
# Wait for the form itself, NOT for a heading matching our stored
# name: a match made through an ALTERNATE name (BGG 140509 is
# "Dungeons & Dragons" to search, "Dragones Y Mazmorras" on the
# page) would never show it. The /boardgame/<id>/ URL already
# guarantees which game this is.
dialog.get_by_role("checkbox", name="Own", exact=True).wait_for(timeout=15_000)
# exact: "Own" is a substring of "Prev. Owned", and a loose label
# match resolves to both checkboxes (strict-mode violation)
dialog.get_by_role("checkbox", name="Own", exact=True).check()
@@ -747,6 +777,18 @@ def run_upload(
to_update = _read_csv(cfg.to_update_path)
log_rows = _read_csv(log_path)
# The queue is a snapshot; review decisions since the last diff win.
stale = stale_jobs(to_add + to_update, read_matches(cfg.matches_path))
if stale:
to_add = [r for r in to_add if r.get("bgg_id") not in stale]
to_update = [r for r in to_update if r.get("bgg_id") not in stale]
typer.echo(
f"Skipping {len(stale)} queued game(s) whose review decision "
"changed since the last diff — re-run diff to refresh the queue:"
)
for bgg_id, why in stale.items():
typer.echo(f" {bgg_id}: {why}")
jobs, skipped_done, skipped_failed, deferred = build_queue(
to_add, to_update, log_rows, retry_failed=retry_failed
)