feat(discord-bridge): punte Discord -> Claude Code pe LXC 171
Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri, 3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu proces persistent per fir alimentat pe stdin cu --input-format stream-json. Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream (parser tolerant), session_store (scriere atomica, lock per fir, detectare PID reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost pe zi), render (un loop de editare per canal, interval adaptiv). Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor, comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou. Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa). Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd user cu KillMode=control-group si limite de memorie, install.sh idempotent. Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s intr-un tool call de 25s schimba raspunsul final). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
118
proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py
Normal file
118
proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""T8: 4 procese, coada per fir, rate limit, plafon de cost."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import limits as L
|
||||
|
||||
|
||||
def mkl(store=None, **kw):
|
||||
kw.setdefault("alerter", lambda *a, **k: None)
|
||||
return L.Limits(store, **kw)
|
||||
|
||||
|
||||
async def test_maxim_4_procese_al_cincilea_asteapta():
|
||||
lim = mkl(max_procs=4)
|
||||
held = []
|
||||
ev = asyncio.Event()
|
||||
|
||||
async def hold(i):
|
||||
async with lim.process_slot():
|
||||
held.append(i)
|
||||
await ev.wait()
|
||||
|
||||
tasks = [asyncio.create_task(hold(i)) for i in range(5)]
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(held) == 4 and lim.free_slots == 0
|
||||
ev.set()
|
||||
await asyncio.gather(*tasks)
|
||||
assert len(held) == 5 and lim.free_slots == 4
|
||||
|
||||
|
||||
async def test_coada_per_fir_serializeaza_acelasi_fir():
|
||||
lim = mkl()
|
||||
ordine = []
|
||||
|
||||
async def tur(tag, delay):
|
||||
async with lim.thread_lock("1"):
|
||||
ordine.append(f"start-{tag}")
|
||||
await asyncio.sleep(delay)
|
||||
ordine.append(f"stop-{tag}")
|
||||
|
||||
await asyncio.gather(tur("a", 0.02), tur("b", 0.0))
|
||||
assert ordine == ["start-a", "stop-a", "start-b", "stop-b"]
|
||||
|
||||
|
||||
async def test_fire_diferite_merg_in_paralel():
|
||||
lim = mkl()
|
||||
async with lim.thread_lock("1"):
|
||||
assert lim.queued("1") and not lim.queued("2")
|
||||
async with lim.thread_lock("2"):
|
||||
pass
|
||||
|
||||
|
||||
def test_rate_limit_per_user():
|
||||
t = [1000.0]
|
||||
lim = mkl(rate_per_min=3, clock=lambda: t[0])
|
||||
for _ in range(3):
|
||||
lim.admit("u1")
|
||||
with pytest.raises(L.RateLimited) as exc:
|
||||
lim.admit("u1")
|
||||
assert exc.value.retry_after == pytest.approx(60.0)
|
||||
lim.admit("u2") # alt utilizator nu e afectat
|
||||
t[0] += 61.0
|
||||
lim.admit("u1") # fereastra a trecut
|
||||
|
||||
|
||||
def test_plafon_de_cost_opreste_botul(store):
|
||||
lim = mkl(store, cost_cap=1.0)
|
||||
assert not lim.stopped() and lim.cost_remaining() == pytest.approx(1.0)
|
||||
lim.record_cost(0.4, "1")
|
||||
lim.admit("u1")
|
||||
assert lim.record_cost(0.7, "1") == pytest.approx(1.1)
|
||||
assert lim.stopped() and lim.cost_remaining() == 0.0
|
||||
with pytest.raises(L.CostCapReached):
|
||||
lim.admit("u1")
|
||||
|
||||
|
||||
def test_plafon_alerteaza_o_singura_data(store):
|
||||
alerte = []
|
||||
lim = mkl(store, cost_cap=0.5, alerter=lambda *a, **k: alerte.append(a))
|
||||
lim.record_cost(0.6, "1")
|
||||
lim.record_cost(0.1, "1")
|
||||
assert len(alerte) == 1 and alerte[0][0] == "CRITICAL"
|
||||
|
||||
|
||||
def test_plafonul_se_ridica_a_doua_zi(store):
|
||||
lim = mkl(store, cost_cap=1.0)
|
||||
lim.record_cost(2.0, "1")
|
||||
assert lim.stopped()
|
||||
store.state["cost"]["day"] = "2000-01-01" # ziua se schimba
|
||||
assert not lim.stopped()
|
||||
|
||||
|
||||
def test_costul_fara_store_e_local():
|
||||
lim = mkl(None, cost_cap=1.0)
|
||||
lim.record_cost(0.5)
|
||||
lim.record_cost("gunoi")
|
||||
assert lim.cost_today() == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_valorile_implicite_vin_din_config(state_dir):
|
||||
import config
|
||||
(state_dir / "env").write_text("COST_CAP_USD_DAY=2.5\nMAX_PROCS=2 # comentariu\n")
|
||||
config.reload(state_dir)
|
||||
lim = mkl()
|
||||
assert lim.cost_cap == 2.5 and lim.max_procs == 2
|
||||
assert lim.turn_timeout == 900.0
|
||||
|
||||
|
||||
async def test_contextul_turn_verifica_tot(store):
|
||||
lim = mkl(store, max_procs=1, cost_cap=10.0, turn_timeout=42.0)
|
||||
async with lim.turn("1", "u1") as timeout:
|
||||
assert timeout == 42.0
|
||||
assert lim.free_slots == 0
|
||||
assert lim.free_slots == 1
|
||||
Reference in New Issue
Block a user