Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 14 additions & 0 deletions cognition/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
56 changes: 46 additions & 10 deletions cognition/scripts/pg-notify-listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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}',
Expand Down Expand Up @@ -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}',
Expand Down Expand Up @@ -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):
Expand Down
19 changes: 12 additions & 7 deletions cognition/tests/test_pg_notify_listener_issue_399.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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:
Expand Down
15 changes: 9 additions & 6 deletions cognition/tests/test_pg_notify_listener_issue_506.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading