Skip to content

Commit b0a886a

Browse files
committed
Add resumable CC:Ladder runs (ladder run --resume)
1 parent 090caf0 commit b0a886a

3 files changed

Lines changed: 105 additions & 14 deletions

File tree

codeclash/agents/player.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ def __init__(
3030
self.environment = environment
3131
self.game_context = game_context
3232
self.push = config.get("push", False)
33+
# Force the branch push (used on ladder resume, to overwrite an interrupted rung's partial
34+
# pushes). Safe here because a run's push branch has a single writer. Uses --force-with-lease.
35+
self._force_push = config.get("force_push", False)
3336
self.logger = get_logger(
3437
self.name,
3538
log_path=self.game_context.log_local / "players" / self.name / "player.log",
@@ -140,8 +143,9 @@ def post_run_hook(self, *, round: int) -> None:
140143
self._write_changes_to_file(round=round)
141144

142145
if self.push:
146+
force = " --force-with-lease" if self._force_push else ""
143147
for cmd in [
144-
f"git push -u origin {self._branch_name}",
148+
f"git push{force} -u origin {self._branch_name}",
145149
"git push origin --tags",
146150
]:
147151
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)

codeclash/cli/ladder.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,18 @@ def run(
4949
keep_containers: bool = typer.Option(
5050
False, "--keep-containers", "-k", help="Do not remove containers after games/agent finish."
5151
),
52+
resume_from: Path | None = typer.Option(
53+
None,
54+
"--resume",
55+
"-r",
56+
help="Resume an interrupted run: pass its log dir. Skips cleared rungs and seeds from the "
57+
"last cleared rung's pushed codebase. Requires push: True and the same config.",
58+
),
5259
):
5360
"""Send a model up a ranked ladder, rung by rung, until it loses.
5461
5562
[dim]• codeclash ladder run path/to/ladder_config.yaml -c # clean up after each rung[/dim]
63+
[dim]• codeclash ladder run path/to/ladder_config.yaml -r logs/<user>/LadderTournament... # resume[/dim]
5664
"""
5765
config = _load_config(config_path)
5866
try:
@@ -62,6 +70,7 @@ def run(
6270
suffix=suffix,
6371
cleanup=cleanup,
6472
keep_containers=keep_containers,
73+
resume_from=resume_from,
6574
)
6675
except ValueError as e:
6776
typer.echo(str(e))

codeclash/tournaments/ladder.py

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import copy
1515
import getpass
1616
import json
17+
import shutil
1718
import time
1819
from concurrent.futures import ThreadPoolExecutor, as_completed
1920
from 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

Comments
 (0)