From c4976e291c0fe2e6564287dc5460c7e8ed036603 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:39:40 +0000 Subject: [PATCH] fix(challenge): allow pausing a v2 run PUT /v1/admin/runs/{id}/paused went through the v1 `_run()` lookup, which raises 409 "route and stored contract version differ" for any RunManifestV2. Production had 4 stale v2 runs stuck on round 0 that could not be shown as stopped without writing to their DBs by hand. For v2 the route is now a display-only running<->paused flip: no v1 config/beacon/round-zero branch, and a `created` run is refused (409) so unpausing never starts a run that never opened a round. open_round_v2 still sets `running` when an operator opens a round. Rejected: a direct SQL UPDATE on the live child DBs (bypasses the app's connection and invariants; refused by the Principal). Co-Authored-By: Claude Opus 5.5 --- src/hypertrain/challenge/store.py | 10 ++++++++++ tests/challenge/test_service_network_v2.py | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/hypertrain/challenge/store.py b/src/hypertrain/challenge/store.py index 2b84ca5..176cba1 100644 --- a/src/hypertrain/challenge/store.py +++ b/src/hypertrain/challenge/store.py @@ -817,6 +817,16 @@ def configure(self, run_id: str, config: dict[str, int]) -> dict[str, Any]: def set_paused(self, run_id: str, paused: bool) -> dict[str, Any]: with self._tx() as db: + row = db.execute( + "SELECT status, manifest FROM runs WHERE run_id=?", (run_id,) + ).fetchone() + if row is not None and json.loads(row["manifest"]).get("manifest_version") == 2: + # v2 rounds own their state; only a running<->paused display flip is allowed. + want, have = ("paused", "running") if paused else ("running", "paused") + if row["status"] not in (want, have): + raise ChallengeError(409, f"v2 run is {row['status']}, not {have}") + db.execute("UPDATE runs SET status=? WHERE run_id=?", (want, run_id)) + return {"run_id": run_id, "status": want} row, manifest = self._run(db, run_id) if paused: db.execute("UPDATE runs SET status='paused' WHERE run_id=?", (run_id,)) diff --git a/tests/challenge/test_service_network_v2.py b/tests/challenge/test_service_network_v2.py index 5d5356c..bc86c21 100644 --- a/tests/challenge/test_service_network_v2.py +++ b/tests/challenge/test_service_network_v2.py @@ -4446,3 +4446,25 @@ def grants(): assert grants() == 2 retry = request(manifest(payload), expiry=584) assert retry.status_code == 200 and retry.json() == honest.json() and grants() == 2 + + +def test_admin_pause_flips_only_an_opened_v2_run_without_touching_its_state(network): + pause = "/v1/admin/runs/" + network.manifest.run_id() + "/paused" + # Given a created run: unpausing must never start a run that never opened a round. + refused = network.client.put(pause, json={"paused": False}, headers=admin()) + assert refused.status_code == 409 + assert network.client.get(network.url).json()["status"] == "created" + # Given a running v2 run (status set as open_round_v2 does). + with network.store._tx(): + network.store._db.execute("UPDATE runs SET status='running'") + before = network.store._db.execute("SELECT manifest, envelope FROM runs").fetchone() + # When / Then: pause and unpause are status-only flips. + paused = network.client.put(pause, json={"paused": True}, headers=admin()) + assert paused.status_code == 200, paused.text + assert network.client.get(network.url).json()["status"] == "paused" + resumed = network.client.put(pause, json={"paused": False}, headers=admin()) + assert resumed.status_code == 200, resumed.text + assert network.client.get(network.url).json()["status"] == "running" + assert tuple(network.store._db.execute("SELECT manifest, envelope FROM runs").fetchone()) == ( + tuple(before) + )