diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index e0a5153..61756e4 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -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:` | Schimba directorul de lucru al firului (ex. `/cd cale:/workspace/romfastsql`) | | `/model model:` | 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`. | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/bot.py b/proxmox/lxc171-claude-agent/discord-bridge/bot.py index 51ec02e..31cf0e5 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/bot.py @@ -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() diff --git a/proxmox/lxc171-claude-agent/discord-bridge/stream.py b/proxmox/lxc171-claude-agent/discord-bridge/stream.py index c752e13..add1713 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/stream.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/stream.py @@ -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) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py index b9bfdd5..2759698 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py @@ -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"))) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py index 3f4ea52..ecad48e 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py @@ -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") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py index 1f62e30..7e0ed0e 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py @@ -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