diff --git a/requirements-ci.txt b/requirements-ci.txt index 02ca2719..5144662a 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml -o requirements-ci.txt --extra dev --extra memory --python-version 3.11 +# uv pip compile pyproject.toml -o /tmp/regen-req.txt --extra dev --extra memory --python-version 3.11 aiohappyeyeballs==2.7.1 # via aiohttp aiohttp==3.14.3 @@ -34,7 +34,7 @@ httpcore==1.0.9 # via httpx httpx==0.28.1 # via rune (pyproject.toml) -idna==3.19 +idna==3.20 # via # anyio # httpx diff --git a/scripts/ci_event_wait.py b/scripts/ci_event_wait.py index c0a185b2..a628f795 100644 --- a/scripts/ci_event_wait.py +++ b/scripts/ci_event_wait.py @@ -134,6 +134,17 @@ OPERATION_SPECS = { DEFAULT_OP_TYPE = "master_ci" +def normalize_verdict(verdict: str) -> str: + """T21.5 §18 audit fix: pending-class verdicts are ALL nonterminal. + deploy_qualify emits "pending (probe failed)" for a transient probe + failure — that is pending, never a terminal answer. The wait loop + must not return control to the model over a string variant.""" + v = (verdict or "").strip().lower() + if v.startswith("pending"): + return "pending" + return (verdict or "").strip() + + def op_spec(op_type: str) -> dict: spec = OPERATION_SPECS.get(op_type) if spec is None: @@ -290,9 +301,9 @@ def run(*, sha: str, deadline_secs: int, "%Y-%m-%dT%H:%M:%SZ", time.gmtime()) resumed_at = event_at # the event is a WAKE, never truth — canonical authority decides - verdict = authority_after_wake(op_type, sha) + verdict = normalize_verdict(authority_after_wake(op_type, sha)) if verdict == "pending" and not op_ci_alive(op_type, sha): - verdict = op_authority_verdict(op_type, sha) + verdict = normalize_verdict(op_authority_verdict(op_type, sha)) mode = ("event_replayed" if verdict != "pending" else "authority_unresolved") verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -340,7 +351,7 @@ def run(*, sha: str, deadline_secs: int, time.gmtime()) # WAKE received — canonical authority decides, never the # event payload - verdict = authority_after_wake(op_type, sha) + verdict = normalize_verdict(authority_after_wake(op_type, sha)) if verdict != "pending": mode = "event" break @@ -360,7 +371,7 @@ def run(*, sha: str, deadline_secs: int, "%Y-%m-%dT%H:%M:%SZ", time.gmtime()) resumed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - verdict = authority_after_wake(op_type, sha) + verdict = normalize_verdict(authority_after_wake(op_type, sha)) if verdict != "pending": mode = "event_recovered_from_durable_record" break @@ -374,26 +385,30 @@ def run(*, sha: str, deadline_secs: int, rearm_reason = ("watchdog expiry; op runs non-terminal " "in Gitea — internal re-arm") continue - # no live runs for THIS op's authority: terminal-in-Gitea but - # possibly aggregator-lagging — bounded re-read, then resolve - verdict = op_authority_verdict(op_type, sha) - if verdict == "pending": - # T21.4 cause C fixed: a pending verdict with no live runs - # may still be a lagging aggregator; one more bounded - # re-read before resolving truthfully. Still no control - # returned to the model — deadline governs. - verdict = op_authority_verdict(op_type, sha) - if verdict == "pending": - mode = "watchdog_lost_event_no_verdict" - else: + # no live runs visible for THIS op's authority: either the + # operation has not started yet (EXPECTED nonterminal — the + # §18-audit generator: this must re-arm, never return to the + # model) or it finished between probes. Bounded re-read. + verdict = normalize_verdict(op_authority_verdict(op_type, sha)) + if verdict != "pending": mode = "watchdog_authority_recovered" - break + break + verdict = normalize_verdict(op_authority_verdict(op_type, sha)) + if verdict != "pending": + mode = "watchdog_authority_recovered" + break + # still pending: runs not started OR lagging aggregator — + # internal re-arm; the deadline is the only other exit + rearm_count += 1 + rearm_reason = ("watchdog expiry; op pending (not started or " + "lagging) — internal re-arm") + continue finally: os.close(fifo_fd) if verdict == "pending": if mode == "event": mode = "deadline_pending" - verdict = op_authority_verdict(op_type, sha) # final one-shot truth + verdict = normalize_verdict(op_authority_verdict(op_type, sha)) # final one-shot truth verdict_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) out = { "sha": sha, diff --git a/tests/test_single_logical_await.py b/tests/test_single_logical_await.py index ada4e0d2..c7c8038f 100644 --- a/tests/test_single_logical_await.py +++ b/tests/test_single_logical_await.py @@ -134,6 +134,56 @@ def test_single_logical_wait_survives_nonterminal_wakes(wr_daemon, monkeypatch): assert n >= 3 # authority re-read internally after each wake +def test_pending_probe_failed_is_nonterminal(wr_daemon, monkeypatch): + """§18 audit fix: "pending (probe failed)" is a pending-class verdict — + the tool must re-arm internally, never return it as terminal.""" + mod = _load() + assert mod.normalize_verdict("pending (probe failed)") == "pending" + assert mod.normalize_verdict("pending") == "pending" + assert mod.normalize_verdict("success") == "success" + # live: authority alternates probe-failure and pending, then success; + # ONE run() call absorbs all of it + rc, n = _single_wait_scenario( + wr_daemon, monkeypatch, + ["pending (probe failed)", "pending", "success"]) + assert rc == 0 + assert n >= 3 + + +def test_not_started_operation_rearms_internally(wr_daemon, monkeypatch): + """§18 audit fix: runs not visible yet is EXPECTED nonterminal — + internal re-arm, no model return.""" + mod = _load() + seq = {"n": 0} + + def authority(op_type, sha): + seq["n"] += 1 + return "pending" if seq["n"] <= 2 else "success" + + monkeypatch.setattr(mod, "op_authority_verdict", authority) + monkeypatch.setattr(mod, "op_ci_alive", lambda t, sha: False) + + def post_wakes(): + for _ in range(3): + deadline = time.time() + 10 + while time.time() < deadline: + if ew.operation_wait(OP) is not None: + wr_daemon.post_event({"operation_id": OP, + "provider": "gitea", + "event_type": "ci_completed", + "sha": SHA, "exit_code": 0, + "completed_at": time.time()}) + break + time.sleep(0.05) + time.sleep(1.0) + + threading.Thread(target=post_wakes, daemon=True).start() + rc = mod.run(sha=SHA, deadline_secs=30, watchdog_secs=3, + op_type="branch_ci", daemon_url=wr_daemon.url()) + assert rc == 0 + assert seq["n"] >= 3 + + def test_deadline_is_the_only_model_return_for_pending(wr_daemon, monkeypatch): """Authority stays pending the whole time: run() returns only at real