From b84205b4e0b4d00a51ac976b8fc29915dc4a374a Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Sun, 1 Nov 2026 18:17:17 +0100 Subject: [PATCH 1/3] fix: keep webhook runtime and monitor coherent Ensure package runtime changes reload webhook ingress, resolve the monitor CLI from the active environment, and propagate monitor failures to systemd. Refs #299 Co-authored-by: Eduard Carreras --- src/github_agent_bridge/autoupdate.py | 22 ++++++------ src/github_agent_bridge/monitor_alert.py | 12 +++++-- tests/test_autoupdate.py | 20 +++++++++++ tests/test_monitor_alert.py | 46 ++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/src/github_agent_bridge/autoupdate.py b/src/github_agent_bridge/autoupdate.py index 7d1fb8b..6478d20 100644 --- a/src/github_agent_bridge/autoupdate.py +++ b/src/github_agent_bridge/autoupdate.py @@ -44,15 +44,8 @@ "src/github_agent_bridge/dashboard_data.py", "src/github_agent_bridge/dashboard_static/", ) -WEBHOOK_PATH_PREFIXES = ( - "src/github_agent_bridge/backend.py", - "src/github_agent_bridge/models.py", - "src/github_agent_bridge/parser.py", - "src/github_agent_bridge/policy.py", - "src/github_agent_bridge/queue.py", - "src/github_agent_bridge/sql/", - "src/github_agent_bridge/webhook.py", -) +WEBHOOK_PATH_PREFIXES = ("src/github_agent_bridge/",) +WEBHOOK_PATH_EXCLUSIONS = ("src/github_agent_bridge/dashboard_static/",) SYSTEMD_PATH_PREFIXES = ("systemd/",) DEFAULT_SYSTEMD_UNITS = { "executor": "github-agent-bridge.service", @@ -147,7 +140,16 @@ def classify_changed_files(files: Sequence[str]) -> dict[str, Any]: risky_files = [path for path in files if path.startswith(RISKY_PATH_PREFIXES)] migration_files = [path for path in files if path.startswith("src/github_agent_bridge/sql/") or "/migrations/" in path] dashboard_files = [path for path in files if path.startswith(DASHBOARD_PATH_PREFIXES)] - webhook_files = [path for path in files if path.startswith(WEBHOOK_PATH_PREFIXES)] + # The webhook entry point imports backend.py, which in turn imports most of + # the package. Treat package code as shared runtime code so an update cannot + # leave ingress running an older import graph. Dashboard static assets are + # the only package subtree that is not loaded by the webhook process. + webhook_files = [ + path + for path in files + if path.startswith(WEBHOOK_PATH_PREFIXES) + and not path.startswith(WEBHOOK_PATH_EXCLUSIONS) + ] systemd_files = [path for path in files if path.startswith(SYSTEMD_PATH_PREFIXES)] dashboard_only = bool(files) and len(dashboard_files) == len(files) and not webhook_files webhook_only = bool(files) and len(webhook_files) == len(files) and not dashboard_files and not risky_files diff --git a/src/github_agent_bridge/monitor_alert.py b/src/github_agent_bridge/monitor_alert.py index 0dbe7d8..2d207bb 100644 --- a/src/github_agent_bridge/monitor_alert.py +++ b/src/github_agent_bridge/monitor_alert.py @@ -6,6 +6,7 @@ import re import signal import subprocess +import sys import time from dataclasses import dataclass from pathlib import Path @@ -14,6 +15,11 @@ from .observability import configure_sentry +def _default_bridge_bin() -> str: + """Resolve the bridge CLI installed beside this monitor entry point.""" + return str(Path(sys.executable).with_name("github-agent-bridge")) + + @dataclass(frozen=True) class AlertConfig: bridge_bin: str @@ -57,7 +63,7 @@ def from_env(cls, env: dict[str, str] | None = None) -> AlertConfig: values = env or os.environ state_dir = Path(values.get("GITHUB_AGENT_BRIDGE_STATE_DIR", "~/.local/state/github-agent-bridge")).expanduser() return cls( - bridge_bin=values.get("GITHUB_AGENT_BRIDGE_BIN", "~/.local/bin/github-agent-bridge"), + bridge_bin=values.get("GITHUB_AGENT_BRIDGE_BIN", _default_bridge_bin()), openclaw_bin=values.get("GITHUB_AGENT_BRIDGE_OPENCLAW_BIN", "~/.nvm/versions/node/v24.14.0/bin/openclaw"), db=values.get("GITHUB_AGENT_BRIDGE_DB", "~/.local/state/github-agent-bridge/bridge.sqlite3"), policy=values.get("GITHUB_AGENT_BRIDGE_POLICY", "~/.config/github-agent-bridge/policy.json"), @@ -517,10 +523,10 @@ def main(argv: list[str] | None = None) -> int: print(unlock_output, end="" if unlock_output.endswith("\n") else "\n") if not should_send_alert(config.state_file, output, config.resend_seconds): - return 0 + return monitor_proc.returncode send_alert(config, output, unlock_output) - return 0 + return monitor_proc.returncode if __name__ == "__main__": diff --git a/tests/test_autoupdate.py b/tests/test_autoupdate.py index 6cca8eb..ede8436 100644 --- a/tests/test_autoupdate.py +++ b/tests/test_autoupdate.py @@ -35,6 +35,25 @@ def test_persistence_runtime_changes_require_executor_reload(): ] +@pytest.mark.parametrize( + "changed_file", + [ + "src/github_agent_bridge/actionability.py", + "src/github_agent_bridge/persistence/webhooks.py", + ], +) +def test_shared_runtime_changes_restart_webhook_ingress(changed_file): + classification = classify_changed_files([changed_file]) + plan = plan_systemd_actions("stage_full_reload", classification) + + assert classification["webhook_files"] == [changed_file] + assert [item["unit"] for item in plan["immediate"]] == [ + "github-agent-bridge-dashboard.service", + "github-agent-bridge-webhook.service", + "github-agent-bridge.service", + ] + + def test_latest_release_reports_missing_gh_as_runtime_error(): with pytest.raises(RuntimeError, match=r"No such file or directory.*missing-gh"): latest_release("gisce/github-agent-bridge", gh_bin="/missing-gh") @@ -421,6 +440,7 @@ def test_full_update_is_allowed_when_queue_is_quiet(tmp_path, monkeypatch): assert plan["executor_restart_allowed"] is True assert [item["unit"] for item in plan["service_plan"]["immediate"]] == [ "github-agent-bridge-dashboard.service", + "github-agent-bridge-webhook.service", "github-agent-bridge.service", ] diff --git a/tests/test_monitor_alert.py b/tests/test_monitor_alert.py index c6ee928..43867c6 100644 --- a/tests/test_monitor_alert.py +++ b/tests/test_monitor_alert.py @@ -1,5 +1,7 @@ from __future__ import annotations +import subprocess +import sys from pathlib import Path from github_agent_bridge import monitor_alert @@ -34,6 +36,50 @@ def test_should_send_alert_persists_and_throttles(tmp_path): assert monitor_alert.should_send_alert(state_file, "boom", resend_seconds=900, now=2001) is True +def test_alert_config_defaults_to_bridge_installed_beside_monitor_python(tmp_path): + config = monitor_alert.AlertConfig.from_env({ + "GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path), + }) + + assert config.bridge_bin == str(Path(sys.executable).with_name("github-agent-bridge")) + + +def test_main_propagates_monitor_failure_after_sending_alert(tmp_path, monkeypatch): + config = make_config(tmp_path) + sent = [] + monkeypatch.setattr(monitor_alert.AlertConfig, "from_env", lambda: config) + monkeypatch.setattr( + monitor_alert, + "run_monitor", + lambda config: subprocess.CompletedProcess([], 2, "invalid monitor arguments\n"), + ) + monkeypatch.setattr(monitor_alert, "maybe_unlock_stale", lambda config, output: "") + monkeypatch.setattr(monitor_alert, "should_send_alert", lambda *args, **kwargs: True) + monkeypatch.setattr(monitor_alert, "send_alert", lambda config, output, unlock: sent.append(output)) + + assert monitor_alert.main() == 2 + assert sent == ["invalid monitor arguments\n"] + + +def test_main_propagates_throttled_monitor_failure(tmp_path, monkeypatch): + config = make_config(tmp_path) + monkeypatch.setattr(monitor_alert.AlertConfig, "from_env", lambda: config) + monkeypatch.setattr( + monitor_alert, + "run_monitor", + lambda config: subprocess.CompletedProcess([], 3, "still failing\n"), + ) + monkeypatch.setattr(monitor_alert, "maybe_unlock_stale", lambda config, output: "") + monkeypatch.setattr(monitor_alert, "should_send_alert", lambda *args, **kwargs: False) + monkeypatch.setattr( + monitor_alert, + "send_alert", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("alert is throttled")), + ) + + assert monitor_alert.main() == 3 + + def test_alert_config_passes_slo_thresholds_from_environment(tmp_path, monkeypatch): config = monitor_alert.AlertConfig.from_env({ "GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path), From 05eeb8894de89464bc1213a403fed166a0fc03fe Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Sun, 1 Nov 2026 18:20:26 +0100 Subject: [PATCH 2/3] fix: expose loaded service version in health checks Make dashboard and webhook runtime freshness directly auditable after deployment. Refs #299 Co-authored-by: Eduard Carreras --- src/github_agent_bridge/backend.py | 7 ++++++- tests/test_backend.py | 1 + tests/test_webhook.py | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/github_agent_bridge/backend.py b/src/github_agent_bridge/backend.py index a716b94..f318dff 100644 --- a/src/github_agent_bridge/backend.py +++ b/src/github_agent_bridge/backend.py @@ -897,7 +897,11 @@ def create_webhook_app(config: DashboardConfig | None = None) -> FastAPI: @app.get("/api/health") def health() -> dict[str, Any]: - return {"ok": True, "service": "github-agent-bridge-webhook-ingress"} + return { + "ok": True, + "service": "github-agent-bridge-webhook-ingress", + "version": __version__, + } @app.post("/api/webhooks/github") async def github_webhook(request: Request) -> dict[str, Any]: @@ -986,6 +990,7 @@ def health() -> dict[str, Any]: return { "ok": bool(metrics.get("db_exists") and metrics.get("schema_ok", True)), "service": "github-agent-bridge-dashboard", + "version": __version__, "db_exists": bool(metrics.get("db_exists")), "schema_ok": bool(metrics.get("schema_ok", True)), "oauth_configured": config.oauth_ready, diff --git a/tests/test_backend.py b/tests/test_backend.py index edc9e12..c4a83f1 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -534,6 +534,7 @@ def test_dashboard_missing_db_does_not_create_database(tmp_path): response = TestClient(app).get("/api/health") assert response.json()["db_exists"] is False + assert response.json()["version"] == __version__ assert db.exists() is False diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 6a16e49..9184517 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -32,6 +32,7 @@ def test_dedicated_webhook_ingress_exposes_only_health_and_delivery(tmp_path): assert client.get("/api/health").json() == { "ok": True, "service": "github-agent-bridge-webhook-ingress", + "version": backend.__version__, } assert client.post( "/api/webhooks/github", content=payload, headers=signed_headers(payload), From c414a23faa78ffe20b65c133004278fbd041261a Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Mon, 2 Nov 2026 11:47:39 +0100 Subject: [PATCH 3/3] fix: resolve monitor CLI beside its entrypoint Use the monitor console script location for both user-site and virtualenv installs while preserving the explicit binary override.\n\nCo-authored-by: ecarreras --- src/github_agent_bridge/monitor_alert.py | 9 +++++-- tests/test_monitor_alert.py | 34 ++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/github_agent_bridge/monitor_alert.py b/src/github_agent_bridge/monitor_alert.py index 2d207bb..50861d6 100644 --- a/src/github_agent_bridge/monitor_alert.py +++ b/src/github_agent_bridge/monitor_alert.py @@ -4,6 +4,7 @@ import json import os import re +import shutil import signal import subprocess import sys @@ -17,7 +18,8 @@ def _default_bridge_bin() -> str: """Resolve the bridge CLI installed beside this monitor entry point.""" - return str(Path(sys.executable).with_name("github-agent-bridge")) + monitor_bin = shutil.which(sys.argv[0]) or sys.argv[0] + return str(Path(monitor_bin).resolve().with_name("github-agent-bridge")) @dataclass(frozen=True) @@ -62,8 +64,11 @@ def proc_state_file(self) -> Path: def from_env(cls, env: dict[str, str] | None = None) -> AlertConfig: values = env or os.environ state_dir = Path(values.get("GITHUB_AGENT_BRIDGE_STATE_DIR", "~/.local/state/github-agent-bridge")).expanduser() + bridge_bin = values.get("GITHUB_AGENT_BRIDGE_BIN") + if bridge_bin is None: + bridge_bin = _default_bridge_bin() return cls( - bridge_bin=values.get("GITHUB_AGENT_BRIDGE_BIN", _default_bridge_bin()), + bridge_bin=bridge_bin, openclaw_bin=values.get("GITHUB_AGENT_BRIDGE_OPENCLAW_BIN", "~/.nvm/versions/node/v24.14.0/bin/openclaw"), db=values.get("GITHUB_AGENT_BRIDGE_DB", "~/.local/state/github-agent-bridge/bridge.sqlite3"), policy=values.get("GITHUB_AGENT_BRIDGE_POLICY", "~/.config/github-agent-bridge/policy.json"), diff --git a/tests/test_monitor_alert.py b/tests/test_monitor_alert.py index 43867c6..1c3befc 100644 --- a/tests/test_monitor_alert.py +++ b/tests/test_monitor_alert.py @@ -36,12 +36,42 @@ def test_should_send_alert_persists_and_throttles(tmp_path): assert monitor_alert.should_send_alert(state_file, "boom", resend_seconds=900, now=2001) is True -def test_alert_config_defaults_to_bridge_installed_beside_monitor_python(tmp_path): +def test_alert_config_defaults_to_bridge_beside_user_entrypoint(tmp_path, monkeypatch): + monitor_bin = tmp_path / ".local/bin/github-agent-bridge-monitor-alert" + monkeypatch.setattr(sys, "argv", ["github-agent-bridge-monitor-alert"]) + monkeypatch.setattr(monitor_alert.shutil, "which", lambda executable: str(monitor_bin)) + + config = monitor_alert.AlertConfig.from_env({ + "GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path), + }) + + assert config.bridge_bin == str(monitor_bin.with_name("github-agent-bridge")) + + +def test_alert_config_defaults_to_bridge_beside_virtualenv_entrypoint(tmp_path, monkeypatch): + monitor_bin = tmp_path / ".venv/bin/github-agent-bridge-monitor-alert" + monkeypatch.setattr(sys, "argv", [str(monitor_bin)]) + monkeypatch.setattr(monitor_alert.shutil, "which", lambda executable: None) + + config = monitor_alert.AlertConfig.from_env({ + "GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path), + }) + + assert config.bridge_bin == str(monitor_bin.with_name("github-agent-bridge")) + + +def test_alert_config_explicit_bridge_bin_overrides_entrypoint_resolution(tmp_path, monkeypatch): + def fail_default(): + raise AssertionError("default resolution must not run for an explicit override") + + monkeypatch.setattr(monitor_alert, "_default_bridge_bin", fail_default) + config = monitor_alert.AlertConfig.from_env({ "GITHUB_AGENT_BRIDGE_STATE_DIR": str(tmp_path), + "GITHUB_AGENT_BRIDGE_BIN": "/opt/github-agent-bridge", }) - assert config.bridge_bin == str(Path(sys.executable).with_name("github-agent-bridge")) + assert config.bridge_bin == "/opt/github-agent-bridge" def test_main_propagates_monitor_failure_after_sending_alert(tmp_path, monkeypatch):