Skip to content

Commit 1f2cb79

Browse files
committed
BattleSnake ladder repairs
1 parent 42d0900 commit 1f2cb79

3 files changed

Lines changed: 57 additions & 26 deletions

File tree

codeclash/agents/player.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ def __init__(
6060

6161
# Handle branch initialization
6262
if branch_init := config.get("branch_init"):
63-
# Fetch from remote first (handles branches pushed in previous tournaments)
64-
# Then checkout - git will create tracking branch if needed
63+
# Fetch, then check out the initial branch (creating a tracking branch if needed).
6564
assert_zero_exit_code(
6665
self.environment.execute(f"git fetch origin && git checkout {branch_init}"),
6766
logger=self.logger,
@@ -70,20 +69,23 @@ def __init__(
7069

7170
if self._branch_name != branch_init:
7271
self.logger.info(f"Switching to branch {self._branch_name} for pushing changes")
73-
# First fetch to see if the branch exists on remote
74-
assert_zero_exit_code(
75-
self.environment.execute("git fetch origin"),
76-
logger=self.logger,
77-
)
78-
# Try to checkout the branch - git will track remote if it exists there
79-
checkout_result = self.environment.execute(f"git checkout {self._branch_name}")
80-
if checkout_result.get("returncode", 0) != 0:
81-
# Branch doesn't exist locally or remotely, create it
82-
self.logger.info(f"Branch {self._branch_name} doesn't exist, creating it")
72+
if branch_init:
73+
# Start the push branch at branch_init. get_environment() pre-created a
74+
# same-named branch at the default branch; a plain checkout would revert the
75+
# working tree to it, so use -B to re-point it at the current HEAD.
8376
assert_zero_exit_code(
84-
self.environment.execute(f"git checkout -b {self._branch_name}"),
77+
self.environment.execute(f"git checkout -B {self._branch_name}"),
8578
logger=self.logger,
8679
)
80+
else:
81+
# Resume the branch if a previous round pushed it to the remote, else create it.
82+
assert_zero_exit_code(self.environment.execute("git fetch origin"), logger=self.logger)
83+
if self.environment.execute(f"git checkout {self._branch_name}").get("returncode", 0) != 0:
84+
self.logger.info(f"Branch {self._branch_name} doesn't exist, creating it")
85+
assert_zero_exit_code(
86+
self.environment.execute(f"git checkout -b {self._branch_name}"),
87+
logger=self.logger,
88+
)
8789

8890
# --- Main methods ---
8991

codeclash/arenas/battlesnake/battlesnake.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,13 @@ def execute_round(self, agents: list[Player]):
127127
try:
128128
self.logger.info(f"Running game with players: {list(player2port.keys())}")
129129

130-
# Use ThreadPoolExecutor for parallel execution
131-
with ThreadPoolExecutor(20) as executor:
130+
# Use ThreadPoolExecutor for parallel execution. Concurrency is configurable
131+
# (game.sim_concurrency): the single-threaded bot servers serialize move
132+
# requests, so total concurrency across all parallel pairs must stay bounded or
133+
# responses exceed the move timeout and games degenerate. When running many pairs
134+
# concurrently (ladder --workers), lower this so workers*sim_concurrency stays ~20.
135+
max_sim_workers = self.game_config.get("sim_concurrency", 20)
136+
with ThreadPoolExecutor(max_sim_workers) as executor:
132137
# Submit all simulations to the thread pool
133138
futures = [
134139
executor.submit(self._run_single_simulation, player2port, idx)

codeclash/cli/ladder.py

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""`codeclash ladder` subcommands: build a ladder (make) and climb it (run)."""
22

3+
import copy
34
import getpass
45
import time
6+
from concurrent.futures import ThreadPoolExecutor, as_completed
57
from pathlib import Path
68

79
import typer
@@ -10,8 +12,11 @@
1012
from codeclash import CONFIG_DIR
1113
from codeclash.constants import LOCAL_LOG_DIR
1214
from codeclash.tournaments.pvp import PvpTournament
15+
from codeclash.utils.log import get_logger
1316
from codeclash.utils.yaml_utils import resolve_includes
1417

18+
logger = get_logger("ladder")
19+
1520
ladder_app = typer.Typer(
1621
no_args_is_help=True, add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}
1722
)
@@ -20,6 +25,9 @@
2025
@ladder_app.command("make")
2126
def make(
2227
config_path: Path = typer.Argument(..., help="Path to the ladder (round-robin) config file."),
28+
workers: int = typer.Option(
29+
1, "--workers", "-w", help="Pairwise tournaments to run concurrently (each pair is independent)."
30+
),
2331
):
2432
"""Build a ladder: run PvP tournaments across all pairs of players (for ranking)."""
2533
yaml_content = config_path.read_text()
@@ -28,24 +36,40 @@ def make(
2836

2937
players = config["players"]
3038
num_players = len(players)
39+
40+
# Build one fully independent (deep-copied) config per pair up front so concurrent runs
41+
# never share or mutate the same player/config dicts.
42+
jobs: list[tuple[dict, Path]] = []
3143
for i in range(num_players):
3244
for j in range(i + 1, num_players):
33-
player1 = players[i]
45+
player1 = copy.deepcopy(players[i])
3446
player1["name"] = player1["branch_init"]
35-
player2 = players[j]
47+
player2 = copy.deepcopy(players[j])
3648
player2["name"] = player2["branch_init"]
37-
pvp_config = {
38-
**config,
39-
"players": [player1, player2],
40-
}
41-
49+
pvp_config = {**copy.deepcopy(config), "players": [player1, player2]}
4250
vs = f"PvpTournament.{player1['name']}_vs_{player2['name']}".replace("/", "_")
4351
output_dir = LOCAL_LOG_DIR / "ladder" / config["game"]["name"] / vs
44-
try:
45-
tournament = PvpTournament(pvp_config, output_dir=output_dir)
46-
except FileExistsError:
47-
continue
52+
jobs.append((pvp_config, output_dir))
53+
54+
def run_pair(pvp_config: dict, output_dir: Path) -> None:
55+
try:
56+
tournament = PvpTournament(pvp_config, output_dir=output_dir)
57+
except FileExistsError:
58+
return # already completed by a previous invocation
59+
# A single failing pair must not abort the rest of a long round-robin.
60+
try:
4861
tournament.run()
62+
except Exception:
63+
logger.exception(f"Pair failed, skipping: {output_dir.name}")
64+
65+
if workers <= 1:
66+
for pvp_config, output_dir in jobs:
67+
run_pair(pvp_config, output_dir)
68+
else:
69+
with ThreadPoolExecutor(max_workers=workers) as executor:
70+
futures = [executor.submit(run_pair, c, d) for c, d in jobs]
71+
for f in as_completed(futures):
72+
f.result()
4973

5074

5175
@ladder_app.command("run")

0 commit comments

Comments
 (0)