| 1 | from __future__ import annotations
|
| 2 |
|
| 3 | import json
|
| 4 | import shutil
|
| 5 | import subprocess
|
| 6 | import sys
|
| 7 | import tempfile
|
| 8 | import threading
|
| 9 | import time
|
| 10 | import unittest
|
| 11 | import urllib.request
|
| 12 | from pathlib import Path
|
| 13 | from unittest import mock
|
| 14 |
|
| 15 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
| 16 |
|
| 17 | import stale_land # noqa: E402
|
| 18 | from oakbench import ci_stub # noqa: E402
|
| 19 | from oakbench.rows import validate_rows # noqa: E402
|
| 20 |
|
| 21 |
|
| 22 | def tree(files: dict[str, str]) -> Path:
|
| 23 | root = Path(tempfile.mkdtemp(prefix="sl-test-"))
|
| 24 | stale_land.write_files(root, files)
|
| 25 | return root
|
| 26 |
|
| 27 |
|
| 28 | class FixtureOracleTests(unittest.TestCase):
|
| 29 | """The semantic-conflict oracle must be real: green alone, red combined."""
|
| 30 |
|
| 31 | def check(self, *patches: str) -> bool:
|
| 32 | files = dict(stale_land.base_files())
|
| 33 | for name in patches:
|
| 34 | files.update(stale_land.agent_patch(name))
|
| 35 | root = tree(files)
|
| 36 | try:
|
| 37 | return stale_land.run_check(root)[0]
|
| 38 | finally:
|
| 39 | shutil.rmtree(root)
|
| 40 |
|
| 41 | def test_base_is_green(self) -> None:
|
| 42 | self.assertTrue(self.check())
|
| 43 |
|
| 44 | def test_pair_members_are_green_alone(self) -> None:
|
| 45 | self.assertTrue(self.check("pair_rename"))
|
| 46 | self.assertTrue(self.check("pair_consumer"))
|
| 47 |
|
| 48 | def test_pair_is_red_combined(self) -> None:
|
| 49 | self.assertFalse(self.check("pair_rename", "pair_consumer"))
|
| 50 |
|
| 51 | def test_ordinary_agents_are_green_together_with_either_pair_member(self) -> None:
|
| 52 | self.assertTrue(self.check("agent00", "agent01", "agent02", "pair_rename"))
|
| 53 | self.assertTrue(self.check("agent00", "agent01", "agent02", "pair_consumer"))
|
| 54 |
|
| 55 | def test_patches_touch_disjoint_paths(self) -> None:
|
| 56 | seen: dict[str, str] = {}
|
| 57 | for name in stale_land.agent_names(8):
|
| 58 | for path in stale_land.agent_patch(name):
|
| 59 | self.assertNotIn(path, seen, f"{name} overlaps {seen.get(path)} on {path}")
|
| 60 | seen[path] = name
|
| 61 | movers = {stale_land.mover_file(j)[0] for j in range(50)}
|
| 62 | self.assertFalse(movers & set(seen))
|
| 63 |
|
| 64 | def test_check_writes_no_bytecode_into_tree(self) -> None:
|
| 65 | root = tree(stale_land.base_files())
|
| 66 | try:
|
| 67 | stale_land.run_check(root)
|
| 68 | self.assertEqual([], [p for p in root.rglob("__pycache__")])
|
| 69 | finally:
|
| 70 | shutil.rmtree(root)
|
| 71 |
|
| 72 |
|
| 73 | class FinalStateClassificationTests(unittest.TestCase):
|
| 74 | def test_exact_with_one_pair_member(self) -> None:
|
| 75 | files = stale_land.expected_final_files(4, 3, "pair_rename")
|
| 76 | result = stale_land.classify_final(files, 4, 3)
|
| 77 | self.assertTrue(result["intended_changes_exact"])
|
| 78 | self.assertEqual(["pair_rename"], result["pair_landed"])
|
| 79 | self.assertEqual([], result["missing_agents"])
|
| 80 |
|
| 81 | def test_both_pair_members_is_not_exact(self) -> None:
|
| 82 | files = stale_land.expected_final_files(1, 1, "pair_rename")
|
| 83 | files.update(stale_land.agent_patch("pair_consumer"))
|
| 84 | result = stale_land.classify_final(files, 1, 1)
|
| 85 | self.assertFalse(result["intended_changes_exact"])
|
| 86 | self.assertTrue(result["both_pair_landed"])
|
| 87 |
|
| 88 | def test_missing_agent_and_extra_file_are_detected(self) -> None:
|
| 89 | files = stale_land.expected_final_files(2, 1, "pair_consumer")
|
| 90 | for path in stale_land.agent_patch("agent01"):
|
| 91 | files.pop(path)
|
| 92 | result = stale_land.classify_final(files, 2, 1)
|
| 93 | self.assertEqual(["agent01"], result["missing_agents"])
|
| 94 | self.assertFalse(result["intended_changes_exact"])
|
| 95 | extra = stale_land.expected_final_files(2, 1, "pair_consumer")
|
| 96 | extra["stray.txt"] = "x"
|
| 97 | self.assertFalse(stale_land.classify_final(extra, 2, 1)["intended_changes_exact"])
|
| 98 |
|
| 99 | def test_read_tree_ignores_vcs_metadata_but_keeps_workflows(self) -> None:
|
| 100 | root = tree(stale_land.base_files())
|
| 101 | try:
|
| 102 | (root / ".oak" / "oak.db").write_text("db")
|
| 103 | (root / ".git").mkdir()
|
| 104 | (root / ".git" / "HEAD").write_text("ref")
|
| 105 | self.assertEqual(stale_land.base_files(), stale_land.read_tree(root))
|
| 106 | self.assertEqual(ci_stub.tree_digest(root), stale_land.files_digest(stale_land.base_files()))
|
| 107 | finally:
|
| 108 | shutil.rmtree(root)
|
| 109 |
|
| 110 |
|
| 111 | class HelperTests(unittest.TestCase):
|
| 112 | def test_last_json_skips_human_preamble(self) -> None:
|
| 113 | text = "Updating 'main'…\nMerged.\n{\"merged\": true, \"commit_hash\": \"abc\"}\n"
|
| 114 | self.assertEqual({"merged": True, "commit_hash": "abc"}, stale_land.last_json(text))
|
| 115 | self.assertIsNone(stale_land.last_json("no json here"))
|
| 116 |
|
| 117 | def test_retry_after_parses_429_hint_only(self) -> None:
|
| 118 | hit = subprocess.CompletedProcess([], 6, "", "Server error: HTTP 429 Too Many Requests: an integrity proof for this repository is already running; retry after 2 seconds")
|
| 119 | self.assertEqual(2.0, stale_land.retry_after(hit))
|
| 120 | self.assertIsNone(stale_land.retry_after(subprocess.CompletedProcess([], 1, "", "HTTP 409 Conflict")))
|
| 121 |
|
| 122 | def test_agent_counts_tool_calls_and_retries_429(self) -> None:
|
| 123 | agent = stale_land.AgentRun(name="agent00", arm="oak_safe")
|
| 124 | responses = [
|
| 125 | subprocess.CompletedProcess([], 6, "", "HTTP 429 Too Many Requests: busy; retry after 0 seconds"),
|
| 126 | subprocess.CompletedProcess([], 0, "{}", ""),
|
| 127 | ]
|
| 128 | with mock.patch.object(stale_land.subprocess, "run", side_effect=responses):
|
| 129 | proc = agent.call_retrying(["oak", "clone"], Path("."), {})
|
| 130 | self.assertEqual(0, proc.returncode)
|
| 131 | self.assertEqual(2, agent.tool_calls)
|
| 132 | self.assertEqual(1, agent.rate_limited)
|
| 133 |
|
| 134 | def test_retryable_conflict_requires_explicit_retry_signal(self) -> None:
|
| 135 | push409 = subprocess.CompletedProcess([], 6, '{"error":{"message":"Server error: HTTP 409 Conflict: a verified blob mapping changed before branch publication; retry"}}', "")
|
| 136 | merge409 = subprocess.CompletedProcess([], 6, "", 'HTTP 409 Conflict {"code":"main_moved","retryable":true}')
|
| 137 | conflict = subprocess.CompletedProcess([], 6, "", "HTTP 409 Conflict: merge conflict in pkg/core.py")
|
| 138 | self.assertTrue(stale_land.retryable_conflict(push409))
|
| 139 | self.assertTrue(stale_land.retryable_conflict(merge409))
|
| 140 | self.assertFalse(stale_land.retryable_conflict(conflict))
|
| 141 | self.assertFalse(stale_land.retryable_conflict(subprocess.CompletedProcess([], 0, "; retry 409", "")))
|
| 142 |
|
| 143 | def test_conflict_retries_are_bounded_and_opt_in(self) -> None:
|
| 144 | busy = subprocess.CompletedProcess([], 6, "", "HTTP 409 Conflict: mapping changed; retry")
|
| 145 | agent = stale_land.AgentRun(name="a", arm="oak_safe")
|
| 146 | with mock.patch.object(stale_land.subprocess, "run", return_value=busy), mock.patch.object(stale_land.time, "sleep"):
|
| 147 | self.assertEqual(6, agent.call_retrying(["oak", "push"], Path("."), {}).returncode)
|
| 148 | self.assertEqual(1, agent.tool_calls)
|
| 149 | agent.call_retrying(["oak", "push"], Path("."), {}, retry_conflicts=2)
|
| 150 | self.assertEqual(1 + 3, agent.tool_calls)
|
| 151 | self.assertEqual(2, agent.conflict_retries)
|
| 152 |
|
| 153 | def test_schedule_expands_oak_arms_per_client_and_git_once(self) -> None:
|
| 154 | args = stale_land.parse_args(
|
| 155 | ["--arms", "oak_safe,git_competent", "--agents", "1,4", "--behind", "1", "--runs", "2",
|
| 156 | "--oak-bin", "a=/x/oak", "--oak-bin", "b=/y/oak"]
|
| 157 | )
|
| 158 | plan = stale_land.schedule(args)
|
| 159 | self.assertEqual(2 * 1 * 2 * (2 + 1), len(plan))
|
| 160 | self.assertEqual({"a", "b", "git"}, {item[1][0] for item in plan})
|
| 161 | with self.assertRaises(SystemExit):
|
| 162 | stale_land.schedule(stale_land.parse_args(["--arms", "bogus"]))
|
| 163 |
|
| 164 |
|
| 165 | class GitArmEndToEndTests(unittest.TestCase):
|
| 166 | """Git arms run fully locally, so they exercise the whole driver and oracles."""
|
| 167 |
|
| 168 | @unittest.skipUnless(shutil.which("git"), "git not installed")
|
| 169 | def test_git_arms_land_everything_and_refuse_the_red_pair_member(self) -> None:
|
| 170 | scratch = Path(tempfile.mkdtemp(prefix="sl-e2e-"))
|
| 171 | try:
|
| 172 | for arm in ("git_competent", "git_fifo"):
|
| 173 | args = stale_land.parse_args(["--scratch", str(scratch), "--ci-added-seconds", "0"])
|
| 174 | with mock.patch.dict("os.environ", {"OAK_BENCH_LOCK_PATH": str(scratch / "lock")}):
|
| 175 | rows = stale_land.run_trial(args, arm, 2, 3, 1, None, {"bench_id": "t"}, ("git", "git", None))
|
| 176 | self.assertEqual([], validate_rows(rows, "contention"))
|
| 177 | trial = rows[-1]
|
| 178 | self.assertEqual("land.trial", trial["operation"])
|
| 179 | self.assertEqual(0, trial["red_main_events"], arm)
|
| 180 | self.assertTrue(trial["intended_changes_exact"], arm)
|
| 181 | self.assertTrue(trial["final_main_green"], arm)
|
| 182 | self.assertEqual(1.0, trial["combined_tested_fraction"], arm)
|
| 183 | outcomes = {r["agent"]: r["outcome"] for r in rows if r["operation"] == "land.agent"}
|
| 184 | self.assertEqual("landed", outcomes["agent00"])
|
| 185 | self.assertEqual("landed", outcomes["agent01"])
|
| 186 | self.assertEqual(
|
| 187 | ["landed", "refused_red_combined"],
|
| 188 | sorted([outcomes["pair_rename"], outcomes["pair_consumer"]]),
|
| 189 | )
|
| 190 | if arm == "git_fifo":
|
| 191 | self.assertEqual(0, trial["retries_total"])
|
| 192 | finally:
|
| 193 | shutil.rmtree(scratch, ignore_errors=True)
|
| 194 |
|
| 195 |
|
| 196 | class BuildRowsNullHonestyTests(unittest.TestCase):
|
| 197 | def test_unlanded_agent_has_null_tested_flag_and_null_time(self) -> None:
|
| 198 | agent = stale_land.AgentRun(name="pair_consumer", arm="oak_safe", outcome="refused_red_combined", finished_at=2.0, attempts=1)
|
| 199 | oracle = {"heads": [], "red": 0, "digests": {}, "final_files": stale_land.expected_final_files(0, 0, "pair_rename"), "ci_trial": [], "ci_setup": []}
|
| 200 | rows = stale_land.build_rows(meta={"bench_id": "t"}, arm="oak_safe", n=0, k=0, run_index=1, agents=[agent], start=1.0, end=2.0,
|
| 201 | setup={"setup_ms": 1.0, "main_commits_before": 1}, oracle=oracle, subject="oak_installed",
|
| 202 | subject_kind="oak", binary_hash=None)
|
| 203 | agent_row = rows[0]
|
| 204 | self.assertIsNone(agent_row["combined_tree_tested_before_land"])
|
| 205 | self.assertIsNone(agent_row["time_to_landed_ms"])
|
| 206 | self.assertEqual(0, agent_row["returncode"])
|
| 207 | self.assertIsNone(rows[-1]["combined_tested_fraction"])
|
| 208 | self.assertEqual([], validate_rows(rows, "contention"))
|
| 209 |
|
| 210 |
|
| 211 | class CiStubProtocolTests(unittest.TestCase):
|
| 212 | """The stub must speak oakspace's signed dispatch/callback protocol."""
|
| 213 |
|
| 214 | def setUp(self) -> None:
|
| 215 | self.tmp = Path(tempfile.mkdtemp(prefix="ci-stub-test-"))
|
| 216 | self.posts: list[tuple[str, dict]] = []
|
| 217 |
|
| 218 | def tearDown(self) -> None:
|
| 219 | shutil.rmtree(self.tmp, ignore_errors=True)
|
| 220 |
|
| 221 | def make(self, **kwargs) -> ci_stub.CiStub: # type: ignore[no-untyped-def]
|
| 222 | return ci_stub.CiStub(api_url="http://127.0.0.1:9", secret="s3cret", ledger=self.tmp / "ledger.jsonl",
|
| 223 | workdir=self.tmp / "work", post=lambda path, body: self.posts.append((path, body)) or True, **kwargs)
|
| 224 |
|
| 225 | def test_refuses_non_loopback_bind(self) -> None:
|
| 226 | with self.assertRaises(ValueError):
|
| 227 | self.make(host="0.0.0.0")
|
| 228 |
|
| 229 | def test_rejects_unsigned_jobs_and_accepts_signed(self) -> None:
|
| 230 | stub = self.make().start()
|
| 231 | try:
|
| 232 | url = f"http://127.0.0.1:{stub.port}/jobs"
|
| 233 | body = json.dumps({"run_id": 7, "repo_id": 1, "commit": "c", "fetch_token": "", "steps": []}).encode()
|
| 234 | bad = urllib.request.Request(url, data=body, method="POST", headers={"x-oak-signature-256": "sha256=00"})
|
| 235 | with self.assertRaises(urllib.error.HTTPError) as ctx:
|
| 236 | urllib.request.urlopen(bad, timeout=5)
|
| 237 | self.assertEqual(401, ctx.exception.code)
|
| 238 | ctx.exception.close()
|
| 239 | good = urllib.request.Request(url, data=body, method="POST", headers={"x-oak-signature-256": "sha256=" + ci_stub.sign("s3cret", body)})
|
| 240 | with urllib.request.urlopen(good, timeout=5) as response:
|
| 241 | self.assertEqual(202, response.status)
|
| 242 | stub.join_runs(10)
|
| 243 | self.assertEqual(("/api/ci/runs/7/conclusion", {"conclusion": "success", "error": None}), self.posts[-1])
|
| 244 | finally:
|
| 245 | stub.stop()
|
| 246 |
|
| 247 | def test_failing_step_reports_step_and_run_failure_then_skips_rest(self) -> None:
|
| 248 | stub = self.make()
|
| 249 | spec = {"run_id": 3, "repo_id": 1, "commit": "c", "fetch_token": "", "env": {"X": "1"},
|
| 250 | "steps": [{"step_id": 1, "name": "a", "run": "test \"$X\" = 1 && test \"$OAK_CI_RUN_ID\" = 3"},
|
| 251 | {"step_id": 2, "name": "b", "run": "exit 4"},
|
| 252 | {"step_id": 3, "name": "c", "run": "true"}]}
|
| 253 | record = stub.execute(spec)
|
| 254 | self.assertEqual("failure", record["conclusion"])
|
| 255 | self.assertEqual(
|
| 256 | [("/api/ci/runs/3/steps", {"step_id": 1, "conclusion": "success", "exit_code": 0}),
|
| 257 | ("/api/ci/runs/3/steps", {"step_id": 2, "conclusion": "failure", "exit_code": 4}),
|
| 258 | ("/api/ci/runs/3/steps", {"step_id": 3, "conclusion": "skipped", "exit_code": None}),
|
| 259 | ("/api/ci/runs/3/conclusion", {"conclusion": "failure", "error": "step 'b' exited 4"})],
|
| 260 | self.posts,
|
| 261 | )
|
| 262 | ledger = ci_stub.read_ledger(self.tmp / "ledger.jsonl")
|
| 263 | self.assertEqual(1, len(ledger))
|
| 264 | self.assertIsNotNone(ledger[0]["tree_digest"])
|
| 265 |
|
| 266 | def test_added_seconds_delays_conclusion(self) -> None:
|
| 267 | stub = self.make(added_seconds=0.3)
|
| 268 | start = time.perf_counter()
|
| 269 | stub.execute({"run_id": 1, "repo_id": 1, "commit": "c", "fetch_token": "", "steps": []})
|
| 270 | self.assertGreaterEqual(time.perf_counter() - start, 0.3)
|
| 271 |
|
| 272 | def test_fetch_failure_concludes_failure_not_wedged(self) -> None:
|
| 273 | stub = self.make()
|
| 274 | with mock.patch.object(stub, "fetch", return_value=subprocess.CompletedProcess([], 128, "", "fatal: nope")):
|
| 275 | record = stub.execute({"run_id": 9, "repo_id": 1, "commit": "c", "fetch_token": "tok", "steps": [{"step_id": 1, "name": "a", "run": "true"}]})
|
| 276 | self.assertEqual("failure", record["conclusion"])
|
| 277 | self.assertIn("source fetch failed", record["error"])
|
| 278 | self.assertEqual("/api/ci/runs/9/conclusion", self.posts[-1][0])
|
| 279 |
|
| 280 |
|
| 281 | if __name__ == "__main__":
|
| 282 | unittest.main()
|