1414import copy
1515import getpass
1616import json
17+ import shutil
1718import time
1819from concurrent .futures import ThreadPoolExecutor , as_completed
1920from pathlib import Path
@@ -143,6 +144,7 @@ def __init__(
143144 suffix : str = "" ,
144145 cleanup : bool = False ,
145146 keep_containers : bool = False ,
147+ resume_from : Path | None = None ,
146148 ):
147149 # Extract ladder-specific keys and strip them from the config that gets handed to each
148150 # per-rung PvpTournament (which only understands `players`).
@@ -161,13 +163,45 @@ def __init__(
161163 self .cleanup = cleanup
162164 self .keep_containers = keep_containers
163165 self .output_dir = output_dir
166+ self ._resuming = resume_from is not None
167+ self ._start_idx = 0
164168
165- timestamp = time .strftime ("%y%m%d%H%M%S" )
166- game_name = config ["game" ]["name" ]
167- ladder_folder = f"LadderTournament.{ game_name } .r{ self .rounds } .s{ self .sims } .{ self .player ['name' ]} .{ timestamp } "
168- self .player ["branch" ] = ladder_folder
169169 base = base_dir if base_dir is not None else LOCAL_LOG_DIR / getpass .getuser ()
170- self .parent_dir = base / ladder_folder
170+
171+ if not self ._resuming :
172+ timestamp = time .strftime ("%y%m%d%H%M%S" )
173+ game_name = config ["game" ]["name" ]
174+ ladder_folder = (
175+ f"LadderTournament.{ game_name } .r{ self .rounds } .s{ self .sims } .{ self .player ['name' ]} .{ timestamp } "
176+ )
177+ self .player ["branch" ] = ladder_folder
178+ self .parent_dir = base / ladder_folder
179+ else :
180+ # Continue the interrupted run IN PLACE: reuse its log dir and its push branch (the dir
181+ # name IS the branch name). The top-level metadata.json is written only after the climb
182+ # loop finishes (win or lose), so its presence means there is nothing to resume.
183+ self .parent_dir = resume_from
184+ self .player ["branch" ] = resume_from .name
185+ if not self .player .get ("push" ):
186+ raise ValueError (
187+ "--resume requires push: True — the pushed branch and round tags are the codebase store."
188+ )
189+ if (resume_from / "metadata.json" ).exists ():
190+ raise ValueError (
191+ f"{ resume_from .name } already finished (top-level metadata.json present); nothing to resume."
192+ )
193+ self ._start_idx , resume_tag = self ._scan_resume (resume_from )
194+ if resume_tag is not None :
195+ self .player ["branch_init" ] = resume_tag
196+ # The first resumed rung force-resets the branch to `resume_tag`, discarding any partial
197+ # rounds the interrupted rung pushed; later rungs then fast-forward normally.
198+ self .player ["force_push" ] = True
199+ msg = (
200+ f"Resuming { resume_from .name } : { self ._start_idx } rung(s) already cleared; "
201+ f"seeding codebase from { resume_tag or self .player .get ('branch_init' )} ."
202+ )
203+ print (msg )
204+ logger .info (msg )
171205
172206 def _advancement_rule_str (self ) -> str :
173207 last_k_rule = "disabled" if self .win_last_k == 0 else f"win the last { self .win_last_k } round(s)"
@@ -176,15 +210,53 @@ def _advancement_rule_str(self) -> str:
176210 f"(baseline round 0 excluded) and { last_k_rule } ."
177211 )
178212
179- def _rung_dir (self , players : list [str ]) -> Path :
213+ def _rung_folder_name (self , players : list [str ]) -> str :
180214 p_num = len (players )
181215 p_list = "." .join (players )
182216 suffix_part = f".{ self .suffix } " if self .suffix else ""
183- folder_name = (
184- f"PvpTournament.{ self .config ['game' ]['name' ]} .r{ self .rounds } .s{ self .sims } .p{ p_num } .{ p_list } { suffix_part } "
185- )
217+ return f"PvpTournament.{ self .config ['game' ]['name' ]} .r{ self .rounds } .s{ self .sims } .p{ p_num } .{ p_list } { suffix_part } "
218+
219+ def _rung_dir (self , players : list [str ]) -> Path :
220+ folder_name = self ._rung_folder_name (players )
186221 return self .parent_dir / folder_name if self .output_dir is None else self .output_dir / folder_name
187222
223+ def _climber_final_tag (self , rung_metadata : dict ) -> str :
224+ """The climber's git tag for the last round of a (cleared) rung — the codebase to carry over."""
225+ for agent in rung_metadata .get ("agents" , []):
226+ if agent .get ("name" ) == self .player ["name" ]:
227+ tags = agent .get ("round_tags" ) or {}
228+ if not tags :
229+ raise ValueError ("A cleared rung has no round tags to resume from (was push enabled?)." )
230+ return tags [max (tags , key = lambda k : int (k ))]
231+ raise ValueError (f"Climber { self .player ['name' ]!r} not found in the resumed rung's metadata." )
232+
233+ def _scan_resume (self , resume_dir : Path ) -> tuple [int , str | None ]:
234+ """Inspect an interrupted run and return ``(start_idx, resume_tag)``: the first rung not yet
235+ cleared, and the climber's codebase tag at the end of the last cleared rung (``None`` if the
236+ run never cleared a rung). Reads only artifacts a normal run already writes — per-rung
237+ ``metadata.json`` (``ladder_advancement.cleared``) and the pushed ``round_tags``.
238+ """
239+ if not resume_dir .exists ():
240+ raise ValueError (f"--resume directory does not exist: { resume_dir } " )
241+ resume_tag : str | None = None
242+ for idx , opponent in enumerate (self .ladder ):
243+ players = [self .player ["name" ], _player_slug (opponent ["branch_init" ])]
244+ meta_path = resume_dir / self ._rung_folder_name (players ) / "metadata.json"
245+ if not meta_path .exists ():
246+ if idx == 0 and not any (resume_dir .glob ("PvpTournament.*" )):
247+ return 0 , None # nothing was completed → equivalent to a fresh run
248+ if idx == 0 :
249+ raise ValueError (f"--resume dir has no rung matching this config: { resume_dir } " )
250+ return idx , resume_tag # reached the interrupted rung
251+ meta = json .loads (meta_path .read_text ())
252+ advancement = meta .get ("ladder_advancement" )
253+ if advancement is None :
254+ return idx , resume_tag # rung ran but never finished → resume here
255+ if not advancement .get ("cleared" ):
256+ raise ValueError (f"Run already ended: climber lost at rung { idx + 1 } . Nothing to resume." )
257+ resume_tag = self ._climber_final_tag (meta )
258+ raise ValueError ("Run already cleared the entire ladder. Nothing to resume." )
259+
188260 def _evaluate_advancement (self , round_winners : list [str ], player_name : str ) -> tuple [int , bool , bool ]:
189261 """Apply the advancement rule to a rung's round winners.
190262
@@ -207,6 +279,8 @@ def run(self) -> dict:
207279 rung = 0
208280 total = len (self .ladder )
209281 for idx , opponent in enumerate (self .ladder ):
282+ if idx < self ._start_idx :
283+ continue # already cleared in the run we're resuming
210284 # `rung` counts the climb from the bottom (1 = weakest opponent faced first, `total` =
211285 # strongest); `opponent_rank` is the opponent's Elo standing (1 = strongest overall).
212286 rung = idx + 1
@@ -215,10 +289,10 @@ def run(self) -> dict:
215289 # Prefix the climber's commit/tag messages with rung context (a prefix carrying its own
216290 # trailing separator; see Player._round_message).
217291 self .player ["commit_label" ] = f"Rung { rung } /{ total } ({ opponent ['name' ]} , elo #{ opponent_rank } ) — "
218- if "branch_init" in self . player and idx > 0 :
219- # After first opponent, remove branch_init so the player continues from the
220- # previous tournament 's codebase.
221- del self .player [ "branch_init" ]
292+ if idx > self . _start_idx :
293+ # After the first executed rung, drop branch_init so the player continues from the
294+ # previous rung 's pushed codebase (carry-over) .
295+ self .player . pop ( "branch_init" , None )
222296 c = {
223297 ** self .config ,
224298 "players" : [
@@ -229,6 +303,10 @@ def run(self) -> dict:
229303
230304 players = [p ["name" ] for p in c ["players" ]]
231305 tournament_dir = self ._rung_dir (players )
306+ # When resuming in place, the interrupted rung left a partial dir; clear it so
307+ # PvpTournament (which refuses a pre-existing metadata.json) can re-run it fresh.
308+ if self ._resuming and idx == self ._start_idx and tournament_dir .exists ():
309+ shutil .rmtree (tournament_dir )
232310 tournament = PvpTournament (
233311 c ,
234312 output_dir = tournament_dir ,
0 commit comments