Skip to content

Commit 16670ee

Browse files
authored
feat: Log loop claim decisions: which candidate was picked and why others were skipped (#124) (#127)
1 parent 633efa7 commit 16670ee

2 files changed

Lines changed: 98 additions & 8 deletions

File tree

loop.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,16 @@ def _spawn_ready(self) -> bool:
848848
(``max_pending_reviews``), and skip a candidate whose ``files_to_modify``
849849
overlap an in-flight build (the hot-file guard — two parallel coders editing
850850
the same file are a guaranteed merge conflict). Returns True if it started at
851-
least one drive (so the runner stays hot)."""
851+
least one drive (so the runner stays hot).
852+
853+
Every tick that reaches the claim scan emits ONE parseable ``claim_decision``
854+
``log.info`` (#124): the fid(s) selected this tick and, for each higher-priority
855+
``ready_queue`` candidate passed over, the structured reason it was skipped
856+
(hot-file overlap with which in-flight fid, ``claim()`` returned None, not
857+
ready/blocked). That is the evidence to tell a lost claim race from the hot-file
858+
guard from a ``ready_queue`` mis-ordering when a lower-priority card claims ahead
859+
of a higher one — the payload is JSON, so a future observer parses it without
860+
grepping log levels."""
852861
if len(self._drives) >= self.max_concurrent:
853862
return False
854863
# Fail-closed gate preflight: if the gate can't run on clean base, HOLD all work
@@ -861,24 +870,49 @@ def _spawn_ready(self) -> bool:
861870
if self.max_pending_reviews and len(store.list_features(state="in_review")) >= self.max_pending_reviews:
862871
return False
863872
spawned = False
864-
busy = set().union(*self._inflight_files.values()) if self._inflight_files else set()
873+
# file → the in-flight (or claimed-this-tick) fid that owns it, so a hot-file
874+
# skip can NAME the build it collides with, not just report "some overlap".
875+
file_owner: dict[str, str] = {}
876+
for owner_fid, owner_files in self._inflight_files.items():
877+
for path in owner_files:
878+
file_owner.setdefault(path, owner_fid)
879+
busy = set(file_owner)
880+
selected: list[str] = []
881+
skipped: list[dict] = [] # {fid, reason, …} per passed-over candidate, priority order
865882
for candidate in store.ready_queue(relaxed=self.relaxed_gate): # priority order, dep-unblocked
866883
if len(self._drives) >= self.max_concurrent:
867-
break
884+
break # remaining candidates are lower priority than what we already selected
885+
cid = candidate["id"]
868886
if candidate.get("board_state") != "ready" or candidate.get("blocked"):
869-
continue # a blocked-flagged feature can carry the `ready` label too
887+
# a blocked-flagged feature can carry the `ready` label too
888+
reason = "blocked" if candidate.get("blocked") else f"state={candidate.get('board_state')}"
889+
skipped.append({"fid": cid, "reason": reason})
890+
continue
870891
files = set(candidate.get("files_to_modify") or [])
871-
if files & busy:
872-
continue # would edit a file an in-flight build owns → defer a tick
873-
claimed = store.claim(candidate["id"], assignee=self.coder_name)
892+
overlap = files & busy
893+
if overlap:
894+
# would edit a file an in-flight build owns → defer a tick
895+
owners = sorted({file_owner[p] for p in overlap})
896+
skipped.append({"fid": cid, "reason": "hot-file", "overlaps": owners, "files": sorted(overlap)})
897+
continue
898+
claimed = store.claim(cid, assignee=self.coder_name)
874899
if claimed is None:
875-
continue # raced / no longer ready
900+
skipped.append({"fid": cid, "reason": "claim-race"}) # raced / no longer ready
901+
continue
876902
self._inflight_files[claimed["id"]] = files
903+
for path in files:
904+
file_owner.setdefault(path, claimed["id"])
877905
task = asyncio.create_task(self._drive(claimed), name=f"pb-drive-{claimed['id']}")
878906
self._drives.add(task)
879907
task.add_done_callback(self._make_drive_done_cb(claimed["id"]))
880908
busy |= files
909+
selected.append(claimed["id"])
881910
spawned = True
911+
if selected or skipped:
912+
log.info(
913+
"[project_board] claim_decision %s",
914+
json.dumps({"selected": selected, "skipped": skipped}, separators=(",", ":"), sort_keys=True),
915+
)
882916
return spawned
883917

884918
def _make_drive_done_cb(self, fid: str):

tests/test_loop.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import asyncio
14+
import json
1415

1516
from project_board import worktree
1617
from project_board.loop import (
@@ -1098,6 +1099,61 @@ async def _quick(feature):
10981099
assert loop._drives == set()
10991100

11001101

1102+
async def test_spawn_ready_logs_the_claim_decision_with_skip_reason(monkeypatch, caplog):
1103+
"""#124: when a lower-priority card claims ahead of a higher one, the single
1104+
per-tick claim_decision line must name the selected fid AND why the higher card
1105+
was passed over — the evidence to tell the hot-file guard from a lost claim race.
1106+
Mirrors the caplog pattern in test_store.test_run_logs_the_retry_count_on_final_success."""
1107+
# bd-hi is FIRST in the ready queue (higher priority) but collides on shared.py with
1108+
# an in-flight build; bd-lo is disjoint and gets claimed ahead of it.
1109+
store = _ClaimStore([_ready("bd-hi", ["shared.py"]), _ready("bd-lo", ["other.py"])])
1110+
monkeypatch.setattr("project_board.loop.get_store", lambda **_kw: store)
1111+
loop = BoardLoop({"max_concurrent": 2})
1112+
loop._inflight_files = {"bd-live": {"shared.py"}} # an in-flight build already owns shared.py
1113+
finish = await _hold_drives(loop, monkeypatch)
1114+
try:
1115+
with caplog.at_level("INFO", logger="protoagent.plugins.project_board"):
1116+
loop._spawn_ready()
1117+
finally:
1118+
await finish()
1119+
assert store.claimed == ["bd-lo"] # the lower-priority card claimed ahead of bd-hi
1120+
lines = [m for m in caplog.messages if "claim_decision" in m]
1121+
assert len(lines) == 1 # exactly one structured line per tick
1122+
payload = json.loads(lines[0].split("claim_decision", 1)[1]) # parseable without log grepping
1123+
assert payload["selected"] == ["bd-lo"] # the selected fid is recorded
1124+
skip = {s["fid"]: s for s in payload["skipped"]}
1125+
assert skip["bd-hi"]["reason"] == "hot-file" # the passed-over card's reason…
1126+
assert skip["bd-hi"]["overlaps"] == ["bd-live"] # …names the in-flight build it collides with
1127+
assert skip["bd-hi"]["files"] == ["shared.py"]
1128+
1129+
1130+
async def test_spawn_ready_logs_a_claim_race_skip(monkeypatch, caplog):
1131+
"""#124: a candidate whose claim() returns None (lost the atomic-claim race) is
1132+
recorded with a distinct, parseable reason — not conflated with the hot-file guard."""
1133+
1134+
class _RacingStore(_ClaimStore):
1135+
def claim(self, fid, assignee=""):
1136+
if fid == "bd-hi":
1137+
return None # someone else won the claim race for the higher card
1138+
return super().claim(fid, assignee=assignee)
1139+
1140+
store = _RacingStore([_ready("bd-hi", ["a.py"]), _ready("bd-lo", ["b.py"])])
1141+
monkeypatch.setattr("project_board.loop.get_store", lambda **_kw: store)
1142+
loop = BoardLoop({"max_concurrent": 2})
1143+
finish = await _hold_drives(loop, monkeypatch)
1144+
try:
1145+
with caplog.at_level("INFO", logger="protoagent.plugins.project_board"):
1146+
loop._spawn_ready()
1147+
finally:
1148+
await finish()
1149+
lines = [m for m in caplog.messages if "claim_decision" in m]
1150+
assert len(lines) == 1
1151+
payload = json.loads(lines[0].split("claim_decision", 1)[1])
1152+
assert payload["selected"] == ["bd-lo"]
1153+
skip = {s["fid"]: s for s in payload["skipped"]}
1154+
assert skip["bd-hi"]["reason"] == "claim-race"
1155+
1156+
11011157
# ── the PR reconcile (terminal-edge fallback) ───────────────────────────────────
11021158

11031159

0 commit comments

Comments
 (0)