diff --git a/CHANGELOG.md b/CHANGELOG.md index 474ea10..723b0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +### Batch: pg-notify-alert-sender-508 (Issue #508) + +#### Fixed +- **`pg-notify-listener.py` alerts use connecting PGUSER as sender and self-safe recipients** (nova-mind#508) — Alert notifications (`_send_push_alert` and `_send_branch_alert`) in the schema-sync listener previously hardcoded the sender as `'schema-sync'`. Because `send_agent_message()` enforces `LOWER(p_sender) == session_user` and no `'schema-sync'` DB user exists, these alerts silently failed to deliver in production since 2026-07-12. The listener now dynamically binds the sender to the connecting `PGUSER` from `_agent_chat_env` (and uses that user to connect to the `agent_chat` database), prefixes the body with `[schema-sync]` for attribution, and applies a smart self-safe recipient resolution strategy (`_alert_recipients()`): + - Excludes the sending user from the recipient list to satisfy the self-address guard in `send_agent_message()` (which throws if the sender is in the recipients list). + - Routes primarily to `nova`. If the sender is `nova` (e.g. running as the primary nova agent), it falls back to `graybeard`. If both match the sender, it falls back to broadcast (`*`). + - Alert helpers remain strictly non-raising; any error along the alert path (missing PGUSER, connection failure, or database error) is logged and swallowed, preventing alert failures from disrupting the schema-sync NOTIFY loop or hiding true sync success/failure outcomes. + +#### Tests +- `cognition/tests/test_pg_notify_listener_issue_508.py` (nova-mind#508) — 23 new tests covering sender binding, message prefixing, recipient self-avoidance (nova → graybeard, graybeard → nova, and fallback broadcast), connection kwarg sourcing, failure swallow/non-raising behavior, raw bound SQL parameter verification, and end-to-end regression paths mirroring #399 and #506 failure states. +- `cognition/tests/test_pg_notify_listener_issue_399.py` and `cognition/tests/test_pg_notify_listener_issue_506.py` (nova-mind#508) — updated pre-existing assertions to align with the new PGUSER sender, message prefix, and self-safe recipient resolver. + +#### Issues Closed +- #508 — pg-notify-listener.py agent_chat alerts are latently broken (hardcoded 'schema-sync' sender rejects) + ### Batch: irc-entity-resolver-522 (Issue #522) #### Added diff --git a/cognition/CHANGELOG.md b/cognition/CHANGELOG.md index e4301dd..4585f85 100644 --- a/cognition/CHANGELOG.md +++ b/cognition/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +### Fixed (#508 — pg-notify-listener alerts use PGUSER sender and self-safe recipients) + +- **`pg-notify-listener.py` alerts (`_send_push_alert` and `_send_branch_alert`) now use connecting PGUSER as sender** ([#508](https://github.com/NOVA-Openclaw/nova-mind/issues/508)) — Replaced the hardcoded `'schema-sync'` sender string with dynamic `_agent_chat_env.get('PGUSER')` in both alert paths. Since `send_agent_message()` enforces `LOWER(p_sender) == session_user` and no `'schema-sync'` database role exists, every listener alert had silently failed to deliver in production since 2026-07-12. +- **Prefix all alert messages with `[schema-sync]`** (#508) — To retain attribution to the schema-sync subsystem when alerts are sent as the connecting user, all push and branch alerts are now prefixed with `[schema-sync]` in the message body. +- **Smart self-safe recipient resolution helper `_alert_recipients(sender)`** (#508) — Added a helper function to resolve alert recipients that dynamically avoids sending an alert to the user that is sending it. This is required because `send_agent_message()` enforces a strict self-address guard (throws an exception if the sender is in the recipient list). + - Routes primarily to `['nova']`. + - If the sender is `nova`, it falls back to `['graybeard']` (Systems Administration / infra owner). + - If both match the sender (e.g., in highly restricted environments), it falls back to broadcast (`['*']`) to ensure the alert is not silently dropped. +- **Strict non-raising failure resilience** (#508) — The alert helpers continue to run within their own safe `try/except` blocks, logging but never propagating any exception (such as a missing `PGUSER` configuration, operational connection error, or database exception). This ensures that an alert failure cannot disrupt the main schema-sync notifier loop. + +#### Tests (#508) +- `cognition/tests/test_pg_notify_listener_issue_508.py` — 23 new tests covering the sender binding, prefixing, recipient avoidance/fallback logic, connection kwargs, failure swallow/non-raising behavior, raw bound SQL params, and end-to-end regression paths under the new contract. +- Existing suites (`test_pg_notify_listener_issue_399.py` and `test_pg_notify_listener_issue_506.py`) updated to assert the correct PGUSER sender, message body prefix, and `_alert_recipients` list structure. + ### Fixed (#506 — branch-safety check for schema-sync commits/pushes) - **`sync_schema_to_github()` now asserts branch == `main` before any dump, commit, or push** ([#506](https://github.com/NOVA-Openclaw/nova-mind/issues/506)) — Prior to this fix, the function never verified the checked-out branch of its working clone (`NOVA_MIND_DIR`, normally `~/.openclaw/workspace/nova-mind`). When the clone was left on a stale feature branch after unrelated work concluded (the root cause observed in production from 2026-07-17 16:23 UTC onward), every subsequent sync attempt committed schema dumps onto that branch and then failed to push (branch diverged from its own remote) — a masked failure: sync events logged `False` to the `events` table, but nothing alerted, so `database/schema.sql` on `main` silently drifted from the live database for days. A new `_ensure_on_main(command, table_name)` check now runs as the first statement inside the existing git-lock critical section (`try` block, immediately after `fcntl.flock(..., LOCK_EX | LOCK_NB)` succeeds) and before the `pgschema dump` step — on **every call**, with no process-lifetime cache, since the listener is a long-running daemon and branch state can change between NOTIFY events (e.g. ops intervention, a stray manual commit). diff --git a/cognition/scripts/pg-notify-listener.py b/cognition/scripts/pg-notify-listener.py index 55c74fc..f599092 100755 --- a/cognition/scripts/pg-notify-listener.py +++ b/cognition/scripts/pg-notify-listener.py @@ -153,6 +153,30 @@ def notify_clawdbot(message): PUSH_BACKOFF_DELAYS = [2, 4] # seconds between retries (exponential: 2^1, 2^2) PUSH_TIMEOUT = 60 # seconds per attempt +# Alert recipient configuration +_ALERT_PRIMARY = ["nova"] +_ALERT_FALLBACK = ["graybeard"] + + +def _alert_recipients(sender: str) -> list[str]: + """Resolve agent_chat alert recipients, excluding the sending user. + + send_agent_message enforces two guards that motivate this helper: + 1. LOWER(p_sender) == session_user: the sender must match the connecting + PGUSER (so we bind the real connecting user, not a fake role). + 2. The sender must NOT appear in the recipient list (self-address guard). + + Primary routing is to nova. When the listener itself is nova, that would + violate guard #2, so we fall back to graybeard (Systems Administration / + infra owner). The final resort is a broadcast so the alert is not dropped. + """ + sender_l = (sender or "").lower() + primary = [r for r in _ALERT_PRIMARY if r.lower() != sender_l] + if primary: + return primary + fallback = [r for r in _ALERT_FALLBACK if r.lower() != sender_l] + return fallback or ["*"] + def _classify_push_failure(stderr): """Classify git push stderr into failure types for retry policy.""" @@ -169,9 +193,15 @@ def _classify_push_failure(stderr): def _send_push_alert(commit_hash, command, table_name, failure_class, stderr): - """Send agent_chat alert to nova on push failure. Never propagates exceptions.""" + """Send agent_chat alert on push failure. Never propagates exceptions.""" try: + sender = _agent_chat_env.get('PGUSER') + if not sender: + log("PGUSER not configured; cannot send push alert") + return + message_lines = [ + '[schema-sync]', f'Schema sync push failed ({failure_class}):', f' repo: nova-mind', f' path: {NOVA_MIND_DIR}', @@ -202,26 +232,32 @@ def _send_push_alert(commit_hash, command, table_name, failure_class, stderr): push_conn = psycopg2.connect( host=_agent_chat_env.get('PGHOST', 'localhost'), database=_agent_chat_env['PGDATABASE'], - user=_agent_chat_env['PGUSER'], + user=sender, password=_agent_chat_env.get('PGPASSWORD', '') ) push_cur = push_conn.cursor() push_cur.execute( "SELECT send_agent_message(%s, %s, %s)", - ('schema-sync', message, ['nova']) + (sender, message, _alert_recipients(sender)) ) push_conn.commit() push_cur.close() push_conn.close() - log(f"Alerted nova via agent_chat about push failure (commit: {commit_hash})") + log(f"Alerted {_alert_recipients(sender)} via agent_chat about push failure (commit: {commit_hash})") except Exception as alert_err: - log(f"Failed to send alert to nova: {alert_err}") + log(f"Failed to send push alert: {alert_err}") def _send_branch_alert(found_branch, command, table_name, reason, stderr=None): - """Send agent_chat alert to nova when branch-safety check aborts. Never propagates exceptions.""" + """Send agent_chat alert when branch-safety check aborts. Never propagates exceptions.""" try: + sender = _agent_chat_env.get('PGUSER') + if not sender: + log("PGUSER not configured; cannot send branch alert") + return + message_lines = [ + '[schema-sync]', f'Schema sync aborted ({reason}):', f' repo: nova-mind', f' path: {NOVA_MIND_DIR}', @@ -261,20 +297,20 @@ def _send_branch_alert(found_branch, command, table_name, reason, stderr=None): alert_conn = psycopg2.connect( host=_agent_chat_env.get('PGHOST', 'localhost'), database=_agent_chat_env['PGDATABASE'], - user=_agent_chat_env['PGUSER'], + user=sender, password=_agent_chat_env.get('PGPASSWORD', '') ) alert_cur = alert_conn.cursor() alert_cur.execute( "SELECT send_agent_message(%s, %s, %s)", - ('schema-sync', message, ['nova']) + (sender, message, _alert_recipients(sender)) ) alert_conn.commit() alert_cur.close() alert_conn.close() - log(f"Alerted nova via agent_chat about branch-safety abort (found: {found_branch}, reason: {reason})") + log(f"Alerted {_alert_recipients(sender)} via agent_chat about branch-safety abort (found: {found_branch}, reason: {reason})") except Exception as alert_err: - log(f"Failed to send branch alert to nova: {alert_err}") + log(f"Failed to send branch alert: {alert_err}") def _ensure_on_main(command, table_name): diff --git a/cognition/tests/test_pg_notify_listener_issue_399.py b/cognition/tests/test_pg_notify_listener_issue_399.py index d4072e3..ba5782c 100644 --- a/cognition/tests/test_pg_notify_listener_issue_399.py +++ b/cognition/tests/test_pg_notify_listener_issue_399.py @@ -106,8 +106,9 @@ def test_alert_to_nova_and_return_semantics( call = message_calls[0] assert call["query"] == "SELECT send_agent_message(%s, %s, %s)" sender, message, recipients = call["params"] - assert sender == "schema-sync" - assert recipients == ["nova"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert recipients == listener_module._alert_recipients(sender) assert "schema sync push failed" in message.lower() assert commit_hash in message assert "Permission denied" not in message @@ -268,7 +269,9 @@ def test_auth_fails_fast_with_distinct_message( message_calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] assert len(message_calls) == 1 sender, message, recipients = message_calls[0]["params"] - assert recipients == ["nova"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert recipients == listener_module._alert_recipients(sender) assert "auth" in message.lower() @@ -344,8 +347,9 @@ def test_no_schema_sync_messages_addressed_to_gidget( # so only the permanent-failure and auth-failure cases produce alerts. assert len(schema_sync_calls) == 2 for call in schema_sync_calls: - recipients = call["params"][2] - assert recipients == ["nova"] + sender, message, recipients = call["params"] + assert message.startswith("[schema-sync]") + assert recipients == listener_module._alert_recipients(sender) assert "gidget" not in [r.lower() for r in recipients] @@ -409,10 +413,11 @@ def test_uses_agent_chat_env_and_valid_signature( assert connect_calls[0]["connect_kwargs"]["database"] == "agent_chat" assert len(message_calls) == 1 sender, message, recipients = message_calls[0]["params"] - assert sender == "schema-sync" + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") assert len(sender) <= 50 assert message - assert recipients == ["nova"] + assert recipients == listener_module._alert_recipients(sender) class TestStaleGidgetInstruction: diff --git a/cognition/tests/test_pg_notify_listener_issue_506.py b/cognition/tests/test_pg_notify_listener_issue_506.py index e08bb86..de68922 100644 --- a/cognition/tests/test_pg_notify_listener_issue_506.py +++ b/cognition/tests/test_pg_notify_listener_issue_506.py @@ -189,8 +189,9 @@ def test_main_diverged_from_origin_aborts_loudly( message_calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] assert len(message_calls) == 1 sender, message, recipients = message_calls[0]["params"] - assert sender == "schema-sync" - assert recipients == ["nova"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert recipients == listener_module._alert_recipients(sender) assert "main" in message.lower() assert "diverged" in message.lower() assert "reconcile manually" in message.lower() @@ -258,8 +259,9 @@ def test_dirty_worktree_on_wrong_branch_does_not_lose_edits( message_calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] assert len(message_calls) >= 1 sender, message, recipients = message_calls[0]["params"] - assert sender == "schema-sync" - assert recipients == ["nova"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert recipients == listener_module._alert_recipients(sender) assert "branch" in message.lower() assert "main" in message.lower() @@ -289,8 +291,9 @@ def test_wrong_branch_remediated_then_push_fails_returns_commit_hash( message_calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] assert len(message_calls) == 1 sender, message, recipients = message_calls[0]["params"] - assert sender == "schema-sync" - assert recipients == ["nova"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert recipients == listener_module._alert_recipients(sender) assert "schema sync push failed" in message.lower() assert commit_hash in message diff --git a/cognition/tests/test_pg_notify_listener_issue_508.py b/cognition/tests/test_pg_notify_listener_issue_508.py new file mode 100644 index 0000000..88a751c --- /dev/null +++ b/cognition/tests/test_pg_notify_listener_issue_508.py @@ -0,0 +1,340 @@ +"""Tests for cognition/scripts/pg-notify-listener.py issue #508. + +Covers the alert sender/recipient fix: + * sender = connecting PGUSER (not hardcoded 'schema-sync') + * message body prefixed with '[schema-sync]' + * recipient strategy excludes the sender and falls back to graybeard/broadcast + * helpers remain non-raising on alert-path failures +""" + +from __future__ import annotations + +import fcntl +import subprocess +import sys +from pathlib import Path + +import psycopg2 +import pytest + +from conftest import ( + pg_notify_listener, + _make_clone_diverged, + _set_schema_content, + _use_fake_git, +) + + +class TestDirectHelperContract: + """TC-508-01/02: direct helper calls pin sender/prefix/recipients.""" + + def test_push_alert_binds_pguser_sender_and_prefixes_message( + self, listener_module, mock_agent_chat + ): + listener_module._send_push_alert( + "abc1234", "CREATE", "public.t", "transient", "boom" + ) + calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + assert len(calls) == 1 + sender, message, recipients = calls[0]["params"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert sender != "schema-sync" + assert message.startswith("[schema-sync]") + assert "abc1234" in message + assert "push failed" in message.lower() + assert recipients == listener_module._alert_recipients(sender) + + def test_branch_alert_binds_pguser_sender_and_prefixes_message( + self, listener_module, mock_agent_chat + ): + listener_module._send_branch_alert( + "feature/x", "ALTER", "public.t", "diverged", "err" + ) + calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + assert len(calls) == 1 + sender, message, recipients = calls[0]["params"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert "diverged" in message.lower() + assert "main" in message.lower() + assert recipients == listener_module._alert_recipients(sender) + + def test_connect_kwargs_still_come_from_agent_chat_env( + self, listener_module, mock_agent_chat + ): + listener_module._send_push_alert("abc1234", "CREATE", "public.t", "auth", "boom") + connect_calls = [c for c in mock_agent_chat if "connect_kwargs" in c] + assert len(connect_calls) == 1 + kwargs = connect_calls[0]["connect_kwargs"] + assert kwargs["database"] == "agent_chat" + assert kwargs["user"] == listener_module._agent_chat_env["PGUSER"] + + +class TestRecipientStrategy: + """TC-508-07 through TC-508-12: recipient resolver properties.""" + + @pytest.mark.parametrize( + "pguser,expected_recipients", + [ + ("testuser", ["nova"]), + ("graybeard", ["nova"]), + ("nova", ["graybeard"]), # CRITICAL self-avoidance (TC-508-09) + ("nova-staging", ["nova"]), + ("newhart", ["nova"]), + ("victoria", ["nova"]), + ], + ) + def test_recipients_follow_strategy_for_pguser( + self, listener_module, mock_agent_chat, monkeypatch, pguser, expected_recipients + ): + monkeypatch.setitem(listener_module._agent_chat_env, "PGUSER", pguser) + listener_module._send_push_alert("abc1234", "CREATE", "public.t", "auth", "boom") + calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + assert len(calls) == 1 + sender, message, recipients = calls[0]["params"] + assert sender == pguser + assert recipients == expected_recipients + assert sender.lower() not in [r.lower() for r in recipients] + assert len(recipients) >= 1 + + def test_recipients_never_include_sender_property( + self, listener_module, mock_agent_chat, monkeypatch + ): + for pguser in ("testuser", "nova", "graybeard", "victoria", "newhart", "nova-staging"): + mock_agent_chat.clear() + monkeypatch.setitem(listener_module._agent_chat_env, "PGUSER", pguser) + listener_module._send_push_alert("h", "C", "t", "transient", None) + calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + assert len(calls) == 1 + sender, _, recipients = calls[0]["params"] + assert sender == pguser + assert sender.lower() not in [r.lower() for r in recipients] + assert len(recipients) >= 1 + + def test_last_resort_broadcast_when_primary_and_fallback_both_match_sender( + self, listener_module, monkeypatch + ): + """Last-resort broadcast when every configured recipient is the sender.""" + monkeypatch.setattr(listener_module, "_ALERT_PRIMARY", ["nova"]) + monkeypatch.setattr(listener_module, "_ALERT_FALLBACK", ["nova"]) + recipients = listener_module._alert_recipients("nova") + assert recipients == ["*"] + + def test_both_primary_and_fallback_are_self_stripped(self, listener_module): + """If primary and fallback both equal sender, final resort is broadcast.""" + recipients = listener_module._alert_recipients("nova") + assert recipients == ["graybeard"] + recipients = listener_module._alert_recipients("graybeard") + assert recipients == ["nova"] + + +class TestFailureSwallow: + """TC-508-13 through TC-508-16: alert path failures do not propagate.""" + + def test_operational_error_on_connect_is_swallowed(self, listener_module, monkeypatch): + def boom(**kwargs): + raise psycopg2.OperationalError("simulated agent_chat outage") + + monkeypatch.setattr(listener_module.psycopg2, "connect", boom) + listener_module._send_push_alert("h", "C", "t", "transient", None) + listener_module._send_branch_alert("b", "C", "t", "fetch failed", None) + + def test_sender_mismatch_error_is_swallowed(self, listener_module, monkeypatch): + class RaisingCursor: + def execute(self, query, params): + raise Exception( + 'send_agent_message: sender must match session_user (got schema-sync but connected as nova)' + ) + + def close(self): + pass + + class RaisingConnection: + def cursor(self): + return RaisingCursor() + + def commit(self): + pass + + def close(self): + pass + + monkeypatch.setattr( + listener_module.psycopg2, "connect", lambda **kwargs: RaisingConnection() + ) + listener_module._send_push_alert("h", "C", "t", "transient", None) + + def test_self_address_error_is_swallowed(self, listener_module, monkeypatch): + class RaisingCursor: + def execute(self, query, params): + raise Exception( + 'send_agent_message: sender "nova" is in the recipient list — agents cannot message themselves' + ) + + def close(self): + pass + + class RaisingConnection: + def cursor(self): + return RaisingCursor() + + def commit(self): + pass + + def close(self): + pass + + monkeypatch.setattr( + listener_module.psycopg2, "connect", lambda **kwargs: RaisingConnection() + ) + listener_module._send_push_alert("h", "C", "t", "transient", None) + + def test_missing_pguser_does_not_raise(self, listener_module, monkeypatch): + monkeypatch.setitem(listener_module._agent_chat_env, "PGUSER", "") + listener_module._send_push_alert("h", "C", "t", "transient", None) + listener_module._send_branch_alert("b", "C", "t", "diverged", None) + + +class TestBoundParams: + """TC-508-17 through TC-508-20: assert raw bound SQL params.""" + + def test_params_are_list_with_three_positions(self, listener_module, mock_agent_chat): + listener_module._send_push_alert("abc1234", "CREATE", "public.t", "auth", "boom") + calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + assert len(calls) == 1 + params = calls[0]["params"] + assert isinstance(params, list) + assert len(params) == 3 + sender, message, recipients = params + assert isinstance(sender, str) + assert isinstance(message, str) + assert isinstance(recipients, (list, tuple)) + assert all(isinstance(r, str) for r in recipients) + + def test_message_non_empty_after_prefix(self, listener_module, mock_agent_chat): + listener_module._send_push_alert("abc1234", "CREATE", "public.t", "auth", "boom") + calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + message = calls[0]["params"][1] + assert message and message.strip() + + def test_stderr_truncation_still_applied_after_prefix( + self, listener_module, mock_agent_chat + ): + long_stderr = "x" * 1000 + listener_module._send_push_alert( + "abc1234", "CREATE", "public.t", "transient", long_stderr + ) + calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + message = calls[0]["params"][1] + assert message.startswith("[schema-sync]") + # The raw stderr slice is at most 500 chars; the full message is longer + # because of the fixed body, so we just verify the stderr portion is truncated. + assert len(message.split("git stderr:")[-1].strip()) <= 500 + + +class TestEndToEndRegression: + """TC-508-03/04 and #399/#506 regression paths with new alert contract.""" + + def test_permanent_push_failure_alerts_with_new_contract( + self, listener_module, git_repos, mock_pgschema_dump, mock_agent_chat, monkeypatch, tmp_path + ): + listener_module.NOVA_MIND_DIR = git_repos["clone"] + listener_module.SCHEMA_FILE = Path(git_repos["clone"]) / "database" / "schema.sql" + _set_schema_content(listener_module, "-- permanent failure schema\n") + _use_fake_git(monkeypatch, tmp_path, "permanent_failure") + + ok, commit_hash = listener_module.sync_schema_to_github( + "CREATE", "table", "public.test_table" + ) + local_head = subprocess.run( + ["git", "-C", git_repos["clone"], "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + assert ok is False + assert commit_hash == local_head + message_calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + assert len(message_calls) == 1 + sender, message, recipients = message_calls[0]["params"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert recipients == listener_module._alert_recipients(sender) + assert not self._lock_is_held(listener_module._git_lock_path) + + def test_diverged_main_branch_alert_with_new_contract( + self, listener_module, git_repos, mock_pgschema_dump, mock_agent_chat + ): + clone = Path(git_repos["clone"]) + origin = Path(git_repos["origin"]) + listener_module.NOVA_MIND_DIR = str(clone) + listener_module.SCHEMA_FILE = str(clone / "database" / "schema.sql") + + _make_clone_diverged(origin, clone) + local_head_before = subprocess.run( + ["git", "-C", str(clone), "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + _set_schema_content(listener_module, "-- diverged schema\n") + ok, commit_hash = listener_module.sync_schema_to_github( + "CREATE", "table", "public.test_table" + ) + + assert ok is False + assert commit_hash is None + local_head_after = subprocess.run( + ["git", "-C", str(clone), "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert local_head_after == local_head_before + + message_calls = [c for c in mock_agent_chat if "send_agent_message" in c.get("query", "")] + assert len(message_calls) == 1 + sender, message, recipients = message_calls[0]["params"] + assert sender == listener_module._agent_chat_env["PGUSER"] + assert message.startswith("[schema-sync]") + assert "diverged" in message.lower() + assert recipients == listener_module._alert_recipients(sender) + + @staticmethod + def _lock_is_held(lock_path): + try: + fd = open(lock_path, "w") + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, fcntl.LOCK_UN) + fd.close() + return False + except (IOError, OSError): + return True + + +class TestStaticContract: + """TC-508-06/26/27/28: source-level contract checks.""" + + def test_no_literal_schema_sync_sender_in_alert_helpers(self, listener_module): + import inspect + + push_src = inspect.getsource(listener_module._send_push_alert) + branch_src = inspect.getsource(listener_module._send_branch_alert) + # The literal 'schema-sync' must not appear as the sender argument. + assert "('schema-sync', message, ['nova'])" not in push_src + assert "('schema-sync', message, ['nova'])" not in branch_src + # The prefix constant must be present. + assert "'[schema-sync]'" in push_src + assert "'[schema-sync]'" in branch_src + + def test_alert_recipients_helper_documents_both_guards(self, listener_module): + doc = listener_module._alert_recipients.__doc__ or "" + lowered = doc.lower() + assert "session_user" in lowered or "pguser" in lowered + assert "self-address" in lowered or "recipient" in lowered + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))