#!/usr/bin/env python3 """notify_event_bridge.py — T16 §16C: ci.yml's final job posts the CI completion WAKE to the local W/R event bridge (daemon /process-event). The event is a WAKE, never completion truth: the waiting controller (ci_event_wait.py) re-reads the canonical Gitea verdict via deploy_qualify.py after resuming. Best-effort by design — if the bridge is unreachable the waiter's watchdog still recovers truthfully (that is the §16D lost-event contract), so a notify failure must NEVER fail CI. REAL_ASYNC_BRIDGE (R3): the completion event is ALSO written directly as a durable completion file (resumes/.done). Delivery no longer depends on the daemon being alive: the waiter blocks on inotify for the file itself (kernel wake, zero latency, zero CPU), and the durable file survives daemon AND waiter restarts (R9). The write is best-effort on the same terms as the POST — never fails CI. """ from __future__ import annotations import json import os import sys import time import urllib.request BRIDGE_URL = os.environ.get("RUNE_EVENT_BRIDGE_URL", "http://127.0.0.1:8791") def main() -> int: sha = os.environ.get("CI_SHA", "") result = os.environ.get("CI_RESULT", "") # needs..result if not sha: print("notify-event-bridge: no CI_SHA — nothing to wake", flush=True) return 0 payload = { "operation_id": f"ci-{sha}", "provider": "gitea", "event_type": "ci_completed", "sha": sha, # exit_code is informational only; the wake is not the verdict "exit_code": 0 if result == "success" else 1, "completed_at": time.time(), } # R8 lost-event failure-recovery: operation-scoped fault injection. # A rule keyed by the EXACT operation_id suppresses BOTH normal # delivery channels (durable completion file AND bridge POST), so the # waiter's watchdog/failure-detector path is the only route to truth. # Disabled by default (no rule file => zero behavior change) and # observable: every decision is recorded in the R8 fault log. try: import r8_fault # noqa: E402 — sibling module if r8_fault.should_suppress(payload["operation_id"]): r8_fault.record(payload["operation_id"], "suppressed", {"sha": sha, "exit_code": payload["exit_code"], "source_terminal_at": payload["completed_at"]}) print("notify-event-bridge: R8 fault injection — completion " f"event intentionally suppressed for " f"{payload['operation_id']} (no .done file, no POST); " "watchdog authority recovery is the expected path", flush=True) return 0 r8_fault.record(payload["operation_id"], "delivered_normally", {"sha": sha, "rule_active": False}) except Exception as exc: # noqa: BLE001 — fault seam must never break delivery print(f"notify-event-bridge: R8 fault-seam probe failed ({exc}); " "continuing with normal delivery", flush=True) # R3 durable delivery: write the completion file FIRST — it works even # if the bridge daemon is down, and the atomic rename means any waiter # (inotify-watching or starting later) sees a complete file or none. try: sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import completion_file as cf # noqa: E402 — sibling module written = cf.write_completion(payload["operation_id"], payload) print(f"notify-event-bridge: durable completion file {written}", flush=True) except Exception as exc: # noqa: BLE001 — delivery is best-effort; watchdog recovery covers a lost completion file (§16D contract) print(f"notify-event-bridge: completion-file write failed ({exc}); " f"bridge POST and watchdog recovery still cover delivery", flush=True) for attempt in range(2): try: req = urllib.request.Request( BRIDGE_URL + "/process-event", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=5) as r: resp = json.loads(r.read()) print(f"notify-event-bridge: woke bridge " f"(matched={resp.get('matched')})", flush=True) return 0 except Exception as exc: # noqa: BLE001 — wake is best-effort by design; failure is logged and the waiter's watchdog recovery covers a lost wake (§16D contract) if attempt == 0: time.sleep(2) else: print(f"notify-event-bridge: bridge unreachable ({exc}); " f"watchdog recovery covers a lost wake", flush=True) return 0 if __name__ == "__main__": sys.exit(main())