@@ -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 ):
0 commit comments