fix(discord-bridge): zgomot in bot.log si doua tipuri de stream necunoscute
Trei lucruri observate in logurile de productie dupa restartul precedent. 1. Ecoul propriilor mesaje umplea bot.log cu WARNING. Propriile mesaje au si ele `author.bot == True`, iar verificarea generica de bot venea INAINTEA celei pe `self_id` — deci raspunsurile botului se jurnalizau ca "bot strain", la fiecare mesaj. Verificarea pe `self_id` trece prima (motivul e acum precis), iar refuzurile de rutina — propriile mesaje si ceilalti boti — merg la DEBUG. Guild / canal / utilizator strain si webhook raman WARNING: alea chiar sunt semnal de securitate si erau inecate in zgomot. 2. `tool_progress` (heartbeat la 30s cat timp o unealta ruleaza) devine eveniment `ToolProgress`. Mesajul live arata acum "⏳ ruleaza de 2m30s" sub unealta curenta — singurul semn ca un tur lung lucreaza si nu a inghetat. 3. `rate_limit_event` devine eveniment `RateLimit` si apare in `/status` la randul `utilizare`. Cum nu exista plafon de cost (abonament, nu API), fereastra de utilizare e singura limita reala; se avertizeaza in log o data per schimbare de stare, nu la fiecare eveniment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
@@ -58,7 +58,7 @@ Cateva alegeri care nu se vad din diagrama:
|
||||
| `/new fork:True` | Sesiune noua care porneste din contextul celei curente |
|
||||
| `/cd cale:<cale>` | Schimba directorul de lucru al firului (ex. `/cd cale:/workspace/romfastsql`) |
|
||||
| `/model model:<sonnet\|opus>` | Schimba modelul pentru firul curent |
|
||||
| `/status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr |
|
||||
| `/status` | Sesiune, director, model, fereastra de utilizare, cost cumulat, proces viu, ultimele linii de stderr |
|
||||
| `/stop` | Opreste turul in desfasurare din firul curent |
|
||||
| `/cleanup` | Listeaza procesele lasate in urma (rulare seaca). `/cleanup force:True` le opreste |
|
||||
| `/permisiuni` | Ce s-a aprobat pentru tot firul. `/permisiuni revoca:True` sterge aprobarile |
|
||||
@@ -273,6 +273,8 @@ tail -2 ~/.claude-discord/logs/alerts.log
|
||||
| Firul e blocat pe hourglass | Botul a fost restartat la mijlocul unui tur. Turul **nu** se reia automat (risc de dubla executie sub `bypassPermissions`); sweep-ul de la pornire pune un avertisment in fir. Trimite mesajul din nou. |
|
||||
| Memoria containerului creste | `/cleanup` (sec), apoi `/cleanup force:True`. Vezi si `systemctl --user show claude-discord -p MemoryCurrent`. |
|
||||
| „Plafon de cost atins" | E limita zilnica, nu o eroare. Pe abonament pune `COST_CAP_USD_DAY=off` in env si reporneste; altfel ridica valoarea sau asteapta ziua urmatoare. |
|
||||
| Un tur pare inghetat | Mesajul live arata `⏳ ruleaza de 2m30s` sub unealta curenta (din `tool_progress`); cat timp numarul creste, turul lucreaza. Daca sta pe loc, `/stop`. |
|
||||
| „Limita de utilizare atinsa" in `/status` | Fereastra abonamentului (`rate_limit_event`), nu plafonul de cost. Randul `utilizare` arata tipul si ora resetarii; nu se poate ocoli, se asteapta. |
|
||||
| Nu vin emailuri de alerta | `command -v mail`; `mailq`; `tail ~/.claude-discord/logs/alerts.log`. Un `NESENT` in log iti spune exact de ce. |
|
||||
| Dupa reboot serviciul nu porneste | `loginctl show-user claude -p Linger` trebuie sa fie `yes`. Daca nu: `sudo loginctl enable-linger claude`. |
|
||||
|
||||
|
||||
@@ -107,6 +107,11 @@ class Decision:
|
||||
|
||||
ACCEPTED = Decision(True)
|
||||
|
||||
# Refuzuri de rutina: botul isi vede propriile mesaje si pe ale altor boti la
|
||||
# fiecare raspuns. Nu sunt semnal de securitate, deci merg la DEBUG — altfel
|
||||
# ineaca in bot.log refuzurile care chiar conteaza (guild/canal/utilizator strain).
|
||||
ROUTINE_REJECTS = ("propriul mesaj", "bot")
|
||||
|
||||
|
||||
def _attr(obj, name, default=None):
|
||||
return getattr(obj, name, default)
|
||||
@@ -141,11 +146,13 @@ def check_message(message, self_id: str | None = None) -> Decision:
|
||||
author = _attr(message, "author")
|
||||
if author is None:
|
||||
return Decision(False, "fara autor")
|
||||
if bool(_attr(author, "bot", False)):
|
||||
return Decision(False, "bot")
|
||||
# Verificarea `self_id` sta INAINTEA celei generice de bot: propriile mesaje au
|
||||
# si ele `author.bot == True`, iar altfel ar fi jurnalizate ca "bot strain".
|
||||
uid = str(_attr(author, "id", ""))
|
||||
if self_id is not None and uid == str(self_id):
|
||||
return Decision(False, "propriul mesaj")
|
||||
if bool(_attr(author, "bot", False)):
|
||||
return Decision(False, "bot")
|
||||
|
||||
guild = _attr(message, "guild")
|
||||
gid = _attr(guild, "id") if guild is not None else _attr(message, "guild_id")
|
||||
@@ -285,6 +292,16 @@ def rss_mb(pid: int | None) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _reset_text(resets_at: int) -> str:
|
||||
"""Momentul resetarii ferestrei, in ora locala. 0 / valoare aiurea -> „?"."""
|
||||
try:
|
||||
if not resets_at:
|
||||
return "?"
|
||||
return time.strftime("%H:%M", time.localtime(int(resets_at)))
|
||||
except (TypeError, ValueError, OSError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _tool_line(ev) -> str:
|
||||
if isinstance(ev, stream_mod.ToolUse):
|
||||
inp = ev.input or {}
|
||||
@@ -294,6 +311,13 @@ def _tool_line(ev) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _progress_line(ev) -> str:
|
||||
"""`tool_progress` -> „ruleaza de 2m30s". Doar asta arata ca turul e viu."""
|
||||
secs = max(0, int(ev.elapsed_s or 0))
|
||||
human = f"{secs}s" if secs < 60 else f"{secs // 60}m{secs % 60:02d}s"
|
||||
return f"⏳ ruleaza de {human}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- Bridge
|
||||
class Bridge:
|
||||
"""Logica adaptorului, fara nicio dependinta de discord.py."""
|
||||
@@ -323,6 +347,10 @@ class Bridge:
|
||||
self.last_result = None # ultimul `result` primit (diagnostic)
|
||||
self.steered = 0
|
||||
self.approvals_wired = False
|
||||
# Ultimul `rate_limit_event` din stream. Cum nu exista plafon de cost
|
||||
# (abonament, nu API), fereastra de utilizare e singura limita reala.
|
||||
self.rate_limit = None
|
||||
self._rate_limit_warned: str | None = None
|
||||
|
||||
# ------------------------------------------------------------ iesire
|
||||
async def _edit(self, target, text: str) -> None:
|
||||
@@ -455,7 +483,9 @@ class Bridge:
|
||||
if not decision.ok:
|
||||
# Refuz TACUT: nu raspundem, nu reactionam. Doar log local.
|
||||
self.rejected.append(decision.reason)
|
||||
log.warning(
|
||||
level = logging.DEBUG if decision.reason in ROUTINE_REJECTS else logging.WARNING
|
||||
log.log(
|
||||
level,
|
||||
"mesaj respins (%s): guild=%s canal=%s user=%s",
|
||||
decision.reason,
|
||||
_attr(_attr(message, "guild"), "id"),
|
||||
@@ -627,6 +657,11 @@ class Bridge:
|
||||
f"in coada : {'da' if self.limits.queued(thread_id) else 'nu'}",
|
||||
f"procese : {self.runner.live_count()} vii, {self.limits.free_slots} sloturi libere",
|
||||
f"cost fir : ${float(rec.get('cost_usd_total') or 0.0):.4f}",
|
||||
f"utilizare : " + (
|
||||
"in regula" if (self.rate_limit is None or self.rate_limit.ok)
|
||||
else f"⚠️ {self.rate_limit.status} ({self.rate_limit.limit_type or '?'}), "
|
||||
f"reset {_reset_text(self.rate_limit.resets_at)}"
|
||||
),
|
||||
f"cost azi : ${self.limits.cost_today():.4f}"
|
||||
+ (f" / ${cap:.2f}" if self.limits.cap_enabled else " (fara plafon)")
|
||||
+ (" ⛔ PLAFON ATINS" if self.limits.stopped() else ""),
|
||||
@@ -638,6 +673,19 @@ class Bridge:
|
||||
lines.append("stderr (ultimele linii):\n```\n" + body + "\n```")
|
||||
return "\n".join(lines)
|
||||
|
||||
def note_rate_limit(self, ev) -> None:
|
||||
"""Retine fereastra de utilizare; avertizeaza o data per schimbare de stare."""
|
||||
self.rate_limit = ev
|
||||
if ev.ok:
|
||||
self._rate_limit_warned = None
|
||||
return
|
||||
if self._rate_limit_warned != ev.status:
|
||||
self._rate_limit_warned = ev.status
|
||||
log.warning(
|
||||
"limita de utilizare: status=%s tip=%s reset=%s",
|
||||
ev.status, ev.limit_type or "?", _reset_text(ev.resets_at),
|
||||
)
|
||||
|
||||
async def cmd_permisiuni(self, message, tid: str, cmd: Command) -> None:
|
||||
await self.say(message.channel, self.permissions_text(tid, "revoca" in cmd.args))
|
||||
|
||||
@@ -729,22 +777,30 @@ class Bridge:
|
||||
placeholder = await channel.send("⏳ lucrez…")
|
||||
chunks: list[str] = []
|
||||
tools: list[str] = []
|
||||
progress = "" # ultimul heartbeat al uneltei curente, sters cand incepe alta
|
||||
loop = self.render.loop_for(str(tid))
|
||||
|
||||
async def on_event(ev) -> None:
|
||||
nonlocal progress
|
||||
if isinstance(ev, stream_mod.SystemInit) and ev.session_id:
|
||||
proc.sid = ev.session_id
|
||||
elif isinstance(ev, stream_mod.AssistantText):
|
||||
chunks.append(ev.text)
|
||||
elif isinstance(ev, stream_mod.ToolUse):
|
||||
tools.append(_tool_line(ev))
|
||||
progress = ""
|
||||
elif isinstance(ev, stream_mod.ToolProgress):
|
||||
progress = _progress_line(ev)
|
||||
elif isinstance(ev, stream_mod.RateLimit):
|
||||
self.note_rate_limit(ev)
|
||||
return
|
||||
elif isinstance(ev, runner_mod.SessionRestarted):
|
||||
chunks.append(f"_{ev.text}_\n")
|
||||
else:
|
||||
return
|
||||
live = "".join(chunks)[-LIVE_TAIL:]
|
||||
if tools:
|
||||
live = (live + "\n" + tools[-1]).strip()
|
||||
live = (live + "\n" + tools[-1] + (f" · {progress}" if progress else "")).strip()
|
||||
loop.queue(placeholder, live or "⏳ lucrez…")
|
||||
|
||||
started = time.time()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Reguli (T6):
|
||||
- tip necunoscut -> se logeaza O SINGURA DATA per tip, cu versiunea CLI, si se ignora
|
||||
(asa au fost descoperite `tool_progress` si `rate_limit_event` in claude 2.1.251;
|
||||
cand un tip nou se dovedeste util, i se face un eveniment si iese din lista)
|
||||
- linie non-JSON -> se logeaza si se ignora
|
||||
- EOF inainte de `result` -> StreamEOFError, explicit, fara hang
|
||||
Parser-ul NU arunca niciodata pe continut de stream; singura exceptie e EOF-ul de mai sus.
|
||||
@@ -84,7 +86,41 @@ class Result:
|
||||
raw: dict = field(default_factory=dict, repr=False)
|
||||
|
||||
|
||||
Event = SystemInit | AssistantText | ToolUse | ToolResult | Result
|
||||
@dataclass(frozen=True)
|
||||
class ToolProgress:
|
||||
"""Semn de viata pentru o unealta care ruleaza de mult (`tool_progress`).
|
||||
|
||||
CLI-ul il trimite periodic (heartbeat) cat timp o unealta e in executie. E
|
||||
singurul semnal ca un tur lung inca lucreaza si nu a inghetat.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
tool_id: str | None
|
||||
elapsed_s: int
|
||||
heartbeat: bool = False
|
||||
session_id: str | None = None
|
||||
raw: dict = field(default_factory=dict, repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimit:
|
||||
"""`rate_limit_event`: starea ferestrei de utilizare a abonamentului.
|
||||
|
||||
`status` e "allowed" cand totul e in regula; orice altceva inseamna ca
|
||||
urmatoarele tururi pot fi franate sau refuzate.
|
||||
"""
|
||||
|
||||
status: str
|
||||
limit_type: str = ""
|
||||
resets_at: int = 0
|
||||
raw: dict = field(default_factory=dict, repr=False)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == "allowed"
|
||||
|
||||
|
||||
Event = SystemInit | AssistantText | ToolUse | ToolResult | ToolProgress | RateLimit | Result
|
||||
|
||||
|
||||
def _blocks(msg: Any) -> list[dict]:
|
||||
@@ -228,6 +264,38 @@ class StreamParser:
|
||||
)
|
||||
]
|
||||
|
||||
if typ == "tool_progress":
|
||||
try:
|
||||
elapsed = int(obj.get("elapsed_time_seconds") or 0)
|
||||
except (TypeError, ValueError):
|
||||
elapsed = 0
|
||||
return [
|
||||
ToolProgress(
|
||||
tool_name=str(obj.get("tool_name") or "?"),
|
||||
tool_id=obj.get("tool_use_id"),
|
||||
elapsed_s=elapsed,
|
||||
heartbeat=bool(obj.get("heartbeat")),
|
||||
session_id=self.session_id,
|
||||
raw=obj,
|
||||
)
|
||||
]
|
||||
|
||||
if typ == "rate_limit_event":
|
||||
info = obj.get("rate_limit_info")
|
||||
info = info if isinstance(info, dict) else {}
|
||||
try:
|
||||
resets = int(info.get("resetsAt") or 0)
|
||||
except (TypeError, ValueError):
|
||||
resets = 0
|
||||
return [
|
||||
RateLimit(
|
||||
status=str(info.get("status") or "necunoscut"),
|
||||
limit_type=str(info.get("rateLimitType") or ""),
|
||||
resets_at=resets,
|
||||
raw=obj,
|
||||
)
|
||||
]
|
||||
|
||||
key = str(typ)
|
||||
if key not in self.unknown_types:
|
||||
self.unknown_types.add(key)
|
||||
|
||||
@@ -62,6 +62,16 @@ def test_propriul_mesaj_refuzat(allowed):
|
||||
assert why(msg) == "propriul mesaj"
|
||||
|
||||
|
||||
def test_propriul_mesaj_nu_e_confundat_cu_un_bot_strain(allowed):
|
||||
"""Mesajele botului au `author.bot=True`; motivul trebuie sa fie cel precis.
|
||||
|
||||
Altfel ecoul propriilor raspunsuri se jurnaliza ca "bot" la WARNING si
|
||||
ineca refuzurile care chiar conteaza.
|
||||
"""
|
||||
msg = FakeMessage("salut", author=FakeUser("999", is_bot=True))
|
||||
assert why(msg) == "propriul mesaj"
|
||||
|
||||
|
||||
def test_guild_strain_refuzat(allowed):
|
||||
assert "guild" in why(FakeMessage("salut", guild=FakeGuild("777")))
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Fisierul contine si falsurile folosite de test_allowlist.py si test_commands.py.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -283,6 +284,53 @@ async def test_text_care_incepe_cu_exclamare_dar_nu_e_comanda_merge_la_claude(br
|
||||
assert await bridge.handle_message(FakeMessage("!!! ce faci?", channel=ch)) == "ok"
|
||||
|
||||
|
||||
# ------------------------------------------------ zgomot in log si utilizare
|
||||
async def test_ecoul_propriilor_mesaje_nu_umple_logul_cu_warning(bridge, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
|
||||
msg = FakeMessage("salut", author=FakeUser("999", is_bot=True))
|
||||
assert await bridge.handle_message(msg) == "rejected"
|
||||
assert "mesaj respins" not in caplog.text
|
||||
assert bridge.rejected[-1] == "propriul mesaj" # dar tot se contorizeaza
|
||||
|
||||
|
||||
async def test_utilizatorul_strain_ramane_warning(bridge, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
|
||||
assert await bridge.handle_message(FakeMessage("salut", author=FakeUser("42"))) == "rejected"
|
||||
assert "mesaj respins (utilizator neautorizat 42)" in caplog.text
|
||||
|
||||
|
||||
def test_status_arata_fereastra_de_utilizare(bridge):
|
||||
assert "utilizare : in regula" in bridge.status_text("200")
|
||||
bridge.note_rate_limit(bot.stream_mod.RateLimit(status="allowed", limit_type="five_hour"))
|
||||
assert "utilizare : in regula" in bridge.status_text("200")
|
||||
bridge.note_rate_limit(
|
||||
bot.stream_mod.RateLimit(status="rejected", limit_type="five_hour", resets_at=1788171600)
|
||||
)
|
||||
txt = bridge.status_text("200")
|
||||
assert "⚠️ rejected (five_hour)" in txt and "reset " in txt
|
||||
|
||||
|
||||
def test_limita_atinsa_se_avertizeaza_o_singura_data(bridge, caplog):
|
||||
ev = bot.stream_mod.RateLimit(status="rejected", limit_type="five_hour")
|
||||
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
|
||||
bridge.note_rate_limit(ev)
|
||||
bridge.note_rate_limit(ev)
|
||||
assert caplog.text.count("limita de utilizare") == 1
|
||||
# revenirea la normal rearmeaza avertismentul
|
||||
bridge.note_rate_limit(bot.stream_mod.RateLimit(status="allowed"))
|
||||
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
|
||||
bridge.note_rate_limit(ev)
|
||||
assert caplog.text.count("limita de utilizare") == 2
|
||||
|
||||
|
||||
def test_progresul_uneltei_arata_ca_turul_traieste():
|
||||
ev = bot.stream_mod.ToolProgress(tool_name="Bash", tool_id="t1", elapsed_s=30, heartbeat=True)
|
||||
assert bot._progress_line(ev) == "⏳ ruleaza de 30s"
|
||||
assert bot._progress_line(
|
||||
bot.stream_mod.ToolProgress(tool_name="Bash", tool_id="t1", elapsed_s=150)
|
||||
) == "⏳ ruleaza de 2m30s"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ aprobari
|
||||
def test_decizia_cere_allowlist(bridge, allowed):
|
||||
assert "allowlist" in bridge.decide("777", "req-1", "allow")
|
||||
|
||||
@@ -64,11 +64,11 @@ def test_result_de_eroare_marcat():
|
||||
def test_tip_necunoscut_logat_o_singura_data():
|
||||
calls = []
|
||||
p = P(version_fn=lambda: (calls.append(1), "2.1.251")[1])
|
||||
line = json.dumps({"type": "rate_limit_event", "detail": "x"})
|
||||
line = json.dumps({"type": "tip_care_nu_exista", "detail": "x"})
|
||||
assert p.feed_line(line) == []
|
||||
assert p.feed_line(line) == []
|
||||
assert p.feed_line(json.dumps({"type": "alt_tip_nou"})) == []
|
||||
assert p.unknown_types == {"rate_limit_event", "alt_tip_nou"}
|
||||
assert p.unknown_types == {"tip_care_nu_exista", "alt_tip_nou"}
|
||||
assert len(calls) == 2 # o data per tip, nu per linie
|
||||
|
||||
|
||||
@@ -120,3 +120,54 @@ async def test_aiter_accepta_bytes_si_gunoi():
|
||||
b'{"type":"result","total_cost_usd":0.5}\n',
|
||||
]))]
|
||||
assert len(evs) == 1 and evs[0].total_cost_usd == 0.5
|
||||
|
||||
|
||||
# ------------------------------------------ tool_progress si rate_limit_event
|
||||
# Ambele apareau in bot.log ca "tip necunoscut ignorat" (claude 2.1.251).
|
||||
|
||||
def test_tool_progress_devine_eveniment():
|
||||
p = P()
|
||||
ev = p.feed_line(json.dumps({
|
||||
"type": "tool_progress",
|
||||
"tool_use_id": "toolu_01-heartbeat-0",
|
||||
"tool_name": "Bash",
|
||||
"parent_tool_use_id": "toolu_01",
|
||||
"elapsed_time_seconds": 30,
|
||||
"heartbeat": True,
|
||||
}))[0]
|
||||
assert isinstance(ev, stream.ToolProgress)
|
||||
assert (ev.tool_name, ev.elapsed_s, ev.heartbeat) == ("Bash", 30, True)
|
||||
assert p.unknown_types == set()
|
||||
|
||||
|
||||
def test_tool_progress_cu_campuri_aiurea_nu_arunca():
|
||||
p = P()
|
||||
ev = p.feed_line(json.dumps({"type": "tool_progress", "elapsed_time_seconds": "x"}))[0]
|
||||
assert (ev.tool_name, ev.elapsed_s, ev.heartbeat) == ("?", 0, False)
|
||||
|
||||
|
||||
def test_rate_limit_event_devine_eveniment():
|
||||
p = P()
|
||||
ev = p.feed_line(json.dumps({
|
||||
"type": "rate_limit_event",
|
||||
"rate_limit_info": {
|
||||
"status": "allowed", "resetsAt": 1788171600,
|
||||
"rateLimitType": "five_hour", "overageStatus": "rejected",
|
||||
},
|
||||
}))[0]
|
||||
assert isinstance(ev, stream.RateLimit)
|
||||
assert ev.ok and ev.limit_type == "five_hour" and ev.resets_at == 1788171600
|
||||
assert p.unknown_types == set()
|
||||
|
||||
|
||||
def test_rate_limit_event_franat_nu_e_ok():
|
||||
p = P()
|
||||
ev = p.feed_line(json.dumps(
|
||||
{"type": "rate_limit_event", "rate_limit_info": {"status": "rejected"}}))[0]
|
||||
assert not ev.ok and ev.status == "rejected"
|
||||
|
||||
|
||||
def test_rate_limit_event_fara_info_nu_arunca():
|
||||
p = P()
|
||||
ev = p.feed_line(json.dumps({"type": "rate_limit_event"}))[0]
|
||||
assert not ev.ok and ev.status == "necunoscut" and ev.resets_at == 0
|
||||
|
||||
Reference in New Issue
Block a user