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
119 lines
3.3 KiB
Python
119 lines
3.3 KiB
Python
"""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
|