The two catch-all handlers added in dac6e97 (r8_fault.record evidence
write, notify_event_bridge seam probe) tripped the #126 silent-handler
ratchet (S110 75 -> 77), which cascaded to the lint-baseline contract
test. Both handlers now log to stderr/stdout and continue — the seam
stays fail-open but is no longer silent. S110 back to 75=75.
103 lines
4.8 KiB
Python
103 lines
4.8 KiB
Python
#!/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/<operation_id>.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.<job>.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())
|