Skip to content
Open
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
2 changes: 1 addition & 1 deletion agents/team/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def handle_mention(self, mention: MentionContext) -> AgentReply:
f"{wrap_untrusted(mention.text)}"
)
if not reply:
reply = "Thinking..."
reply = "Sorry, I'm having trouble processing that. Try again or DM me."
card = {
"title": f"New mention {mention.mention_id or ''}".strip(),
"body": mention.text,
Expand Down
3 changes: 3 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ MCP_DISPATCH_AGENT_ID=mcp-orchestrator
# ─────────────────────────────────────────────
XMCP_LAST_SEEN_PATH=~/.xmcp/last_seen.txt
XMCP_DISPATCH_LAST_SEEN=~/.xmcp/dispatch_last_seen.txt
# Ledger of replied-to mention IDs (suppresses duplicate replies on restart)
XMCP_PROCESSED_MENTIONS_PATH=~/.xmcp/processed_mentions.txt
XMCP_MAX_PROCESSED_MENTIONS=10000

# ─────────────────────────────────────────────
# OpenAPI Filtering (optional)
Expand Down
72 changes: 68 additions & 4 deletions listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
from agents.registry import register_team, route_mention

LAST_SEEN_PATH = Path(os.getenv("XMCP_LAST_SEEN_PATH", "~/.xmcp/last_seen.txt")).expanduser()
PROCESSED_MENTIONS_PATH = Path(
os.getenv("XMCP_PROCESSED_MENTIONS_PATH", "~/.xmcp/processed_mentions.txt")
).expanduser()
# The last-seen watermark has second granularity and start_time is inclusive,
# so the newest processed mention is re-fetched on every restart. The
# processed-mentions ledger exists to suppress that duplicate reply; it only
# needs to cover the replay window, not all history.
MAX_PROCESSED_MENTIONS = int(os.getenv("XMCP_MAX_PROCESSED_MENTIONS", "10000"))
Comment on lines 15 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
fd -a 'listener.py|env.example|\.env' . 2>/dev/null || true

echo "== listener outline/size =="
if [ -f listener.py ]; then
  wc -l listener.py
  ast-grep outline listener.py --view compact || true
  echo "== relevant listener.py =="
  sed -n '1,35p;180,225p;260,330p' listener.py
fi

echo "== load_env usages/definition =="
rg -n "def load_env|load_env\(|os\.getenv\(|XMCP_PROCESSED_MENTIONS_PATH|XMCP_MAX_PROCESSED_MENTIONS|XMCP_LAST_SEEN_PATH" . -g '!node_modules' -g '!build' -g '!dist' || true

echo "== env docs =="
if [ -f env.example ]; then
  sed -n '1,160p' env.example
fi

Repository: groupthinking/MyXstack

Length of output: 11637


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

listener = Path("listener.py").read_text()
tree = ast.parse(listener)

module_os_getenv_calls = []
load_env_body = []
main_load_env_calls = []
main_body = []

for node in ast.iter_child_nodes(tree):
    if isinstance(node, ast.Assign):
        if any(isinstance(t, ast.Name) and t.id == "LAST_SEEN_PATH" for t in node.targets
               for t in node.targets if isinstance(t, ast.Name)) or \
           any(isinstance(t, ast.Name) and t.id == "PROCESSED_MENTIONS_PATH" for t in node.targets
               for t in node.targets if isinstance(t, ast.Name)) or \
           any(isinstance(t, ast.Name) and t.id == "MAX_PROCESSED_MENTIONS" for t in node.targets
               for t in node.targets if isinstance(t, ast.Name)):
            for child in ast.walk(node):
                if isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute):
                    if child.func.attr == "getenv":
                        module_os_getenv_calls.append((node.lineno, child.func.value.id, child.func.attr))
    if isinstance(node, ast.FunctionDef) and node.name == "load_env":
        load_env_body.extend([child.lineno for child in ast.walk(node) if hasattr(child, "lineno")])
    if isinstance(node, ast.FunctionDef) and node.name == "main":
        main_load_env_calls.extend((start.lineno, child.lineno)
                                   for start in node.body[:1] if isinstance(start, ast.Expr) and isinstance(start.value, ast.Call) and isinstance(start.value.func, ast.Name) and start.value.func.id == "load_env"
                                   for child in [start])
        main_body.extend([child.lineno for child in ast.walk(node) if hasattr(child, "lineno")])

print("module-level os.getenv calls:", module_os_getenv_calls)
print("load_env function line range approximately:", min(load_env_body) if load_env_body else None, max(load_env_body) if load_env_body else None)
print("main load_env call lines:", [lines[1] for lines in main_load_env_calls])
print("main lines:", min(main_body) if main_body else None, max(main_body) if main_body else None)
print("module-level os.getenv before load_env:", all(l < min(load_env_body) for _, _, l in module_os_getenv_calls))
print("main load_env lines:", sorted(main_load_env_calls))
PY

Repository: groupthinking/MyXstack

Length of output: 539


.env-only ledger config is ignored.

PROCESSED_MENTIONS_PATH and MAX_PROCESSED_MENTIONS are bound at import time. load_env() runs later in main(), so .env values for these keys do not affect the ledger path or cap. Move load_env() before module-level config reads, or resolve these values lazily/inside main().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@listener.py` around lines 15 - 23, Ensure load_env() runs before
PROCESSED_MENTIONS_PATH and MAX_PROCESSED_MENTIONS are evaluated, or resolve
both settings lazily after environment loading in main(). Preserve the existing
environment variable names and defaults so .env values control the ledger path
and cap.

POLL_SECONDS = int(os.getenv("POLL_INTERVAL_SECONDS", "60"))
PAYMENT_REQUIRED_BACKOFF_SECONDS = int(os.getenv("X_PAYMENT_REQUIRED_BACKOFF_SECONDS", "900"))

Expand All @@ -34,6 +42,43 @@ def load_last_seen() -> Optional[str]:
return LAST_SEEN_PATH.read_text(encoding="utf-8").strip() or None


def load_processed_mentions() -> "set[str]":
"""Load recently processed mention IDs, compacting the ledger on the way.

An unreadable ledger must not kill the listener thread — worst case a
few boundary mentions get a second reply, which is preferable to no
mentions being handled at all.
"""
try:
if not PROCESSED_MENTIONS_PATH.exists():
return set()
lines = [
line.strip()
for line in PROCESSED_MENTIONS_PATH.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except OSError as exc:
print(
f"WARNING: could not read {PROCESSED_MENTIONS_PATH}: {exc}; "
"starting with an empty processed-mentions set (duplicate replies possible)",
flush=True,
)
return set()
Comment on lines +45 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fail-open on ledger read errors re-enables the exact duplicate-reply risk already flagged.

This still returns an empty set on any OSError, meaning a transient or permanent read failure resets duplicate suppression to zero and lets every boundary mention get replied to again. The docstring says this is intentional, but it's the same problem previously raised: retry or fail closed instead of quietly reopening the door to duplicate replies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@listener.py` around lines 45 - 66, Update load_processed_mentions so an
OSError while reading PROCESSED_MENTIONS_PATH does not return an empty
processed-mentions set; retry the ledger read or fail closed by preserving
duplicate suppression. Adjust the docstring and warning to reflect the chosen
behavior, while keeping normal loading and compaction unchanged.

if len(lines) > MAX_PROCESSED_MENTIONS:
lines = lines[-MAX_PROCESSED_MENTIONS:]
Comment on lines +67 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

MAX_PROCESSED_MENTIONS=0 doesn't cap anything.

lines[-0:] is lines[0:] — the whole list. Setting the cap to 0 silently disables truncation instead of shrinking it to zero, and a negative value slices from the wrong end. Guard the boundary explicitly.

🔧 Proposed fix
-    if len(lines) > MAX_PROCESSED_MENTIONS:
-        lines = lines[-MAX_PROCESSED_MENTIONS:]
+    if MAX_PROCESSED_MENTIONS <= 0:
+        lines = []
+    elif len(lines) > MAX_PROCESSED_MENTIONS:
+        lines = lines[-MAX_PROCESSED_MENTIONS:]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if len(lines) > MAX_PROCESSED_MENTIONS:
lines = lines[-MAX_PROCESSED_MENTIONS:]
if MAX_PROCESSED_MENTIONS <= 0:
lines = []
elif len(lines) > MAX_PROCESSED_MENTIONS:
lines = lines[-MAX_PROCESSED_MENTIONS:]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@listener.py` around lines 67 - 68, Update the truncation logic near
MAX_PROCESSED_MENTIONS to handle zero and negative limits explicitly: a
non-positive cap must produce an empty lines list, while positive caps retain
only the last MAX_PROCESSED_MENTIONS entries when the list exceeds the limit.

try:
PROCESSED_MENTIONS_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
except OSError as exc:
print(f"WARNING: could not compact {PROCESSED_MENTIONS_PATH}: {exc}", flush=True)
return set(lines)


def save_processed_mention(mention_id: str) -> None:
PROCESSED_MENTIONS_PATH.parent.mkdir(parents=True, exist_ok=True)
with PROCESSED_MENTIONS_PATH.open("a", encoding="utf-8") as f:
f.write(f"{mention_id}\n")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def build_client() -> tweepy.Client:
access_token = os.getenv("X_ACCESS_TOKEN") or os.getenv("X_OAUTH_ACCESS_TOKEN")
access_secret = os.getenv("X_ACCESS_SECRET") or os.getenv("X_OAUTH_ACCESS_TOKEN_SECRET")
Expand Down Expand Up @@ -169,6 +214,7 @@ def main() -> None:
register_team()

last_seen = load_last_seen()
processed_mentions = load_processed_mentions()
start_time = datetime.now(timezone.utc) - timedelta(minutes=10)
if last_seen:
try:
Expand Down Expand Up @@ -205,10 +251,28 @@ def main() -> None:
# the last-seen watermark only ever moves forward.
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
for mention in sorted(mentions.data or [], key=lambda m: m.created_at or epoch):
if not process_mention(client, mention):
# Card push failed: stop here so this mention (and later
# ones) are retried on the next poll.
break
mention_id = str(mention.id)
if mention_id in processed_mentions:
# Already replied (start_time is inclusive, so the boundary
# mention comes back every poll/restart). Still advance the
# watermark so it stops being re-fetched.
print(f"Skipping already processed mention {mention.id}", flush=True)
else:
if not process_mention(client, mention):
# Card push failed: stop here so this mention (and later
# ones) are retried on the next poll.
break
processed_mentions.add(mention_id)
try:
save_processed_mention(mention_id)
except OSError as exc:
# The reply already went out — a persistence failure only
# risks a duplicate after restart, so log it as such
# rather than as a reply failure.
print(
f"WARNING: could not persist processed mention {mention.id}: {exc}",
flush=True,
)
Comment on lines +265 to +275

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persistence failure here still produces a duplicate reply after restart.

When save_processed_mention() raises OSError, the code logs a warning and continues; start_time still advances past this mention right after. On restart, load_last_seen() resumes exactly at this mention, load_processed_mentions() does not contain its ID (the write failed), and it gets replied to a second time. This reproduces the previously flagged risk of treating a persistence failure as harmless when the reply already went out non-idempotently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@listener.py` around lines 265 - 275, Update the exception path around
save_processed_mention in the mention-processing flow so an OSError does not
allow start_time to advance past an unpersisted mention; stop or otherwise retry
processing before advancing the checkpoint, while preserving the existing
warning context and normal success behavior.

start_time = mention.created_at or datetime.now(timezone.utc)
save_last_seen(start_time.isoformat())

Expand Down
Loading