Viewing files at commit 3d84c25e26dbCommit detailsView latest

Browse files
tests/test_stale_land.py 282 lines · 14.2 KB · python
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()