diff --git a/cascade/shared/calendar_launch.py b/cascade/shared/calendar_launch.py index 95f85346..65cabd98 100644 --- a/cascade/shared/calendar_launch.py +++ b/cascade/shared/calendar_launch.py @@ -50,7 +50,32 @@ def check_calendar_launch(cfg, client) -> None: """ if not dated_launch_pending(cfg): return - check_launch_loads(cfg, int(client.finalized_block())) + head = int(client.finalized_block()) + if launch_date_passed(cfg, client, head): + # 2026-11-13: the trial below arms v2 at the era AFTER the head — right + # before the date, wrong after it: the real block is in the past + # (mainnet 9399600), so a typed later switch (``[v2] + # image_decoupled_from_block = 9493200``, #457) "precedes" the fake + # trial block and every node that pulled main refused to start. Past + # the date, the first poll's :func:`resolve_calendar_launch` parses the + # config at the REAL block from finalized timestamps and refuses a + # config that cannot load there. + return + check_launch_loads(cfg, head) + + +def launch_date_passed(cfg, client, head: int) -> bool: + """The finalized head is at or past ``[v2] launch_at_utc`` (chain time). + ``False`` when the head's timestamp cannot be read — the pre-date trial + then runs as before (refusing is the safe side).""" + target = launch_timestamp(cfg.v2.launch_at_utc) + if target is None: + return False + try: + stamp = float(client.block_timestamp(int(head))) + except Exception: # noqa: BLE001 — unreadable chain time: keep the trial + return False + return math.isfinite(stamp) and stamp >= target def _first_finalized_at(client, head, target): diff --git a/tests/unit/test_calendar_launch.py b/tests/unit/test_calendar_launch.py index 69d8d8be..c1c5fb82 100644 --- a/tests/unit/test_calendar_launch.py +++ b/tests/unit/test_calendar_launch.py @@ -118,6 +118,43 @@ def test_service_start_refuses_a_dated_launch_whose_v2_config_cannot_load(): check_calendar_launch(replace(cfg, v2=replace(cfg.v2, launch_at_utc="")), object()) +def test_shipped_file_starts_after_the_launch_date_with_the_typed_v2b_switch(): + # 2026-11-13 regression: main types [v2] image_decoupled_from_block = + # 9493200 (#457); v2 launched 2026-10-30 at 9399600. The startup trial + # armed v2 at the era AFTER the head (9500400) and refused every node + # that pulled main: "image_decoupled_from_block=9493200 precedes [v2] + # from_block=9500400". + from cascade.shared.activation import apply_activation + + shipped = load_chain_config() + assert shipped.v2.image_decoupled_from_block == 9493200 and not shipped.v2.from_block + live = apply_activation(shipped, 9136800) # the fleet's 900 grid + launch = launch_timestamp(shipped.v2.launch_at_utc) + head = 9497650 + past = SimpleNamespace(finalized_block=lambda: head, + block_timestamp=lambda block: launch + (block - 9399000) * 12.0) + check_calendar_launch(live, past) # starts (was: ValueError) + # ... and the date really is behind the head: the real resolution lands on + # the live v2 block and that config loads with the typed switch + resolved = resolve_calendar_launch(live, past) + assert resolved.v2.from_block == 9399600 + assert resolved.v2.image_decoupled_from_block == 9493200 + # BEFORE the date the trial still runs (and still refuses a bad config) + early = SimpleNamespace(finalized_block=lambda: 9390000, + block_timestamp=lambda block: launch - 3600.0) + check_calendar_launch(live, early) + raw = effective_raw(live) + raw["netconfig"]["owner_hotkey"] = "" + with pytest.raises(ValueError, match="launch_at_utc"): + check_calendar_launch(parse_chain_config(raw), early) + # an unreadable head timestamp keeps the trial (the safe side) + def boom(block): + raise RuntimeError("pruned") + dark = SimpleNamespace(finalized_block=lambda: 9390000, block_timestamp=boom) + with pytest.raises(ValueError, match="launch_at_utc"): + check_calendar_launch(parse_chain_config(raw), dark) + + def test_a_pruned_late_joiner_gets_an_actionable_error_not_a_guess(): cfg = load_chain_config() raw = effective_raw(cfg)