# Plan — Steering (mesaje mid-tur) în echo-core **Status:** draft, intrat în /autoplan **Branch:** master **Data:** 2026-09-02 ## Problema Când Echo Core execută un mesaj lung (tool calls, subagenți), un al doilea mesaj trimis de Marius pe același canal **nu ajunge la Claude până când primul tur se termină**. Concret: îi scrii „stai, nu în master" în timp ce lucrează, iar Claude vede corecția abia după ce a terminat ce făcea greșit. Cauza, în cod: - `src/claude_session.py:310` — `subprocess.Popen` **one-shot per mesaj**, promptul trecut ca **argv** (`-p ""`), fără stdin. Procesul moare la finalul turului. - `src/claude_session.py:628` — `send_message()` ia un `threading.Lock` per canal. Mesajul 2 **blochează** în lock până se termină turul 1, apoi pleacă ca tur separat prin `--resume`. - `src/adapters/discord_bot.py:1296` — fiecare mesaj pornește propriul `asyncio.to_thread(route_message, ...)`, deci mesajul 2 chiar ajunge în `send_message`, dar așteaptă acolo. ## Referința — agentul din LXC 171 `romfastsql/proxmox/lxc171-claude-agent/discord-bridge/` implementează exact asta, sub numele **steering**. **Codul rulează pe LXC 171, nu în clona locală** — clona din `~/workspace/romfastsql/` e stale și nu conține `discord-bridge/`. Acces: `ssh echo@10.0.20.201 "sudo pct exec 171 -- cat /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/runner.py"`: - `runner.py` — proces `claude` **persistent per fir**, pornit cu `-p --input-format stream-json --output-format stream-json --verbose`, cu **stdin deschis**. Un tur = scrii un JSON `{"type":"user",...}` pe stdin, citești evenimente de pe stdout până la `result`. - `bot.py:652-663` — dacă vine un mesaj cât `proc.inflight` e True, **nu deschide tur nou**: îl scrie pe același stdin, reacționează cu ➡️, `return "steered"`. - `session_id` luat din evenimentul `system`/`init` → `--resume` la respawn. - Reaper de procese inactive (20 min), timeout per tur, buffer circular de stderr. ## Verificare empirică (făcută înainte de plan) CLI-ul local (claude 2.1.258) suportă `--input-format stream-json`. Spike rulat pe `/tmp`: un tur pornit cu `sleep 12 && echo pas1` în prim-plan (3 pași secvențiali ceruți), apoi la 15.0s s-a scris pe stdin „STOP, schimbare de plan […] răspunde-mi doar cu cuvântul ANANAS". Rezultat: la 17.8s Claude a răspuns `ANANAS`, a abandonat pașii 2 și 3, și turul s-a închis cu **un singur** `result` (`num_turns=2`). Premisa centrală a planului e confirmată: mesajul mid-tur ajunge la model, e luat în seamă, iar turul nu se dublează. Un prim spike a fost neconcludent (Claude a trecut `sleep` în `run_in_background`, turul s-a terminat în 7.8s înainte să conteze steering-ul) — de reținut ca notă de test. ## Decizii deja luate cu utilizatorul | Întrebare | Alegere | |---|---| | Arhitectură | Proces persistent + steering real (nu coadă/coalescing) | | Adaptoare | Toate trei deodată (Discord, Telegram, WhatsApp) | | Feedback UX | Confirmare discretă — reacție ➡️, răspuns unul singur la final | | Scheduler / heartbeat / planning | Rămân one-shot, neatinse | ## Design ### 1. `src/claude_runner.py` (nou) Thread-based, nu asyncio. Motivul: adaptoarele cheamă `route_message` prin `asyncio.to_thread`, deci `send_message` e cod sync; un al doilea event loop în procesul care deja rulează `asyncio.gather()` în `main.py` ar fi complexitate gratuită. **`ClaudeProcess`** — un proces viu per `channel_id`: - spawn cu `-p --input-format stream-json --output-format stream-json --verbose --system-prompt --dangerously-skip-permissions [--resume ]`, `stdin=PIPE` ținut deschis - **thread separat care drenează stderr** într-un `deque(maxlen=50)` — obligatoriu, altfel pipe-ul se umple și procesul blochează la mijlocul unui tur - `run_turn()` întoarce **exact dict-ul pe care îl întoarce azi `_run_claude`** (`result`, `session_id`, `usage`, `total_cost_usd`, `duration_ms`, `subtype`, `is_error`) → restul codului nu se atinge - `steer(text)` — scrie pe stdin sub un `_stdin_lock`, fără să deschidă tur - `session_id` capturat din evenimentul `system`/`init` → `--resume` la respawn **`RunnerRegistry`** — reaper thread care oprește procesele inactive (`steering.idle_minutes`, implicit 20 — vezi X3), niciodată unul `inflight`; plafon `steering.max_live` (implicit 4) cu stop LRU. ### 2. `send_message()` devine dispecer ``` if steering activ: proc = registry.get(channel_id) if proc.alive and proc.inflight: → proc.steer(text); return "__STEERED__" with lock(channel_id): → proc.run_turn(...) + persist în sessions/active.json else: calea actuală, neatinsă ``` `_run_claude`, `start_session`, `resume_session` rămân **neatinse** — le folosesc `heartbeat.py` și `planning_session.py`. **Cursă cunoscută:** dacă turul se închide între verificarea `inflight` și scrierea pe stdin, linia devine automat turul următor (procesul rămâne viu — verificat în spike). Nu se pierde mesajul, dar adaptorul ar fi răspuns deja doar cu ➡️. Tratare: `steer()` care constată post-factum `not inflight` cade înapoi pe tur normal. ### 3. Router + adaptoare Sentinela `__STEERED__` propagată prin `route_message` fără `_set_last_response` — același tipar cu `__AUDIO__:` deja existent în `discord_bot.py:1305`. Discord: `add_reaction("➡️")`. Telegram: `set_message_reaction`. WhatsApp: verific întâi dacă bridge-ul Baileys expune reacții; dacă nu, un „➡️" scurt. ## Riscuri identificate 1. **Rate limit — cel mai subtil.** Detecția de azi (`is_rate_limit_error`) prinde un `RuntimeError` construit din exit code ≠ 0. Un proces persistent nu moare la limită — apare ca `result.is_error` în stream. Netratat, **fallback-ul local nu se mai declanșează deloc** — adică exact munca cea mai grea din repo devine moartă în tăcere. 2. ~~**`--system-prompt` se fixează la spawn.**~~ **ELIMINAT — premisă inversată.** Verificat: `resume_session` (`claude_session.py:565-571`) **nu pasează deloc** `--system-prompt`; `build_system_prompt()` e chemat doar la `:486` (`start_session`) și `scheduler.py:402`. Editările din `personality/` **deja** nu se aplică mid-sesiune, ci doar după `/clear`. Fix-ul propus (hash → respawn) ar fi fost comportament NOU care omoară procesul viu și steering-urile în așteptare la fiecare editare de `SOUL.md`. Vezi § Corecții factuale. 3. **`set_session_model`** trebuie să oprească procesul viu; turul următor îl repornește cu `--resume` (ca `set_options` din bridge). 4. **RAM** — fiecare proces `claude` viu costă. De aici plafonul `max_live`. ## Etape | # | Ce | Verificare | |---|-----|-----------| | 1 | `claude_runner.py` + fake `claude` stream-json + `tests/test_claude_runner.py` | tur normal, steering mid-tur, respawn `--resume` după kill, reaper, timeout, rate-limit în stream — toate offline | | 2 | Dispecerul în `send_message` + sentinela în router, în spatele `config.json → steering.enabled` (**implicit off**) | suita existentă trece nemodificată cu flagul off | | 3 | Reacții pe cele 3 adaptoare | manual, un canal de test | | 4 | Rate-limit din stream + respawn pe personality/model schimbat | test dedicat că fallback-ul local încă pornește | | 5 | Reaper + `max_live` + procesele vii vizibile în `eco status` | | | 6 | Flip pe un canal, apoi global | | Flagul e implicit off la fiecare pas, deci rollback-ul e o linie în `config.json`. --- # /autoplan — Phase 1: CEO Review Mod: **SELECTIVE EXPANSION** (feature enhancement pe sistem existent — default-ul contextual). Voci: `[subagent-only]` — Codex nu e instalat pe această mașină. ## 0A. Premise Challenge | # | Premisă | Verdict | Dovadă | |---|---|---|---| | P1 | Mesajul 2 nu ajunge la Claude până se termină turul 1 | **CONFIRMAT** | `claude_session.py:628` lock blocant; adaptoarele pornesc `to_thread` separat (`discord_bot.py:1296`) | | P2 | Steering prin stdin funcționează pe CLI-ul instalat | **CONFIRMAT** | spike: `ANANAS` la 17.8s, un singur `result`, `num_turns=2` | | P3 | Doar `router.py` cheamă `send_message` → cron/heartbeat/planning sunt izolate | **CONFIRMAT** | `heartbeat.py:416` folosește `_run_claude_extra`; `planning_session.py:264` folosește `_run_claude` | | P4 | „Răspunsul rămâne unul singur, la finalul turului" | **FALS** | echo-core **deja** trimite fiecare bloc intermediar pe canal prin `on_text` (`claude_session.py:355`, `discord_bot.py:1283`, `telegram_bot.py:1020`, `whatsapp.py:217`). Bridge-ul editează un placeholder; echo-core trimite mesaje noi. UX-ul ales de utilizator nu există azi. | | P5 | „WhatsApp poate să nu aibă reacții" | **FALS** | `react_whatsapp()` există și e deja folosit (👀 pus/scos pe fiecare mesaj, `whatsapp.py:243`). Telegram folosește deja `set_message_reaction` (✅). Toate trei adaptoarele au reacții native. | | P6 | Un proces persistent cu `--dangerously-skip-permissions` are același profil de risc ca unul one-shot | **NEEXAMINAT — fals** | Azi procesul moare la finalul turului. Persistent = shell cu acces total la unelte, viu până la 20 min după ultimul mesaj. Raza de acțiune a unei injecții de prompt persistă între tururi. | | P7 | Contextul se gestionează singur | **LACUNĂ** | Bridge-ul pasează explicit `--autocompact auto`. Planul nu-l menționează. Un proces care ține N tururi crește monoton până lovește limita de context. | **Premise queued pentru Final Gate:** P4 (UX-ul ales nu descrie comportamentul real) — vezi Gate. ## 0B. Existing Code Leverage | Sub-problemă | Cod existent | Reutilizat de plan? | |---|---|---| | Parsare stream-json | `_run_claude` `claude_session.py:335-370` | **NU — plan rescrie.** Violare DRY. Trebuie extras parser comun. | | Serializare per canal | `_get_session_lock` `:84` | Da | | Persistare sesiune | `_load_sessions` / `_save_sessions` `:253` | Da | | Detecție rate limit | `is_rate_limit_error` / `rate_limit_detail` `:49` | Parțial — nu acoperă `result.is_error` | | Sentinelă în răspuns | `__AUDIO__:` `discord_bot.py:1305` | Da, precedent bun | | Reacții pe mesaj | Discord `add_reaction`, Telegram `set_message_reaction`, WhatsApp `react_whatsapp` | Da — **toate trei există deja** | | Env sigur pentru subprocess | `_safe_env()` `:231` | Da | | System prompt | `build_system_prompt()` `:424` | Da | | Loop persistent + steering + interrupt | **`claude-agent-sdk` v0.2.151** (`ClaudeSDKClient.query()` / `.interrupt()`) | **NU — plan reimplementează.** Vezi 0C-bis. | ## 0C. Dream State ``` CURRENT STATE THIS PLAN 12-MONTH IDEAL one-shot per mesaj ---> proces persistent ---> un strat unic de sesiune prompt în argv stdin deschis partajat de chat + voice + Ralph: lock → mesajul 2 așteaptă steering mid-tur steering, interrupt/cancel, fără cancel fără cancel atașamente mid-tur, progres live, fără compactare fără compactare compactare + rate limit într-un loc ``` **Delta:** planul ajunge ~60% spre ideal. Lipsesc: **cancel/interrupt** (a opri un tur scăpat de sub control — cea mai cerută capabilitate vecină), decizia voice-vs-text pe același canal, compactarea. ## 0C-bis. Implementation Alternatives ``` APPROACH A: Port hand-rolled al bridge-ului (planul actual) Summary: ClaudeProcess + RunnerRegistry thread-based, stdin deschis, parser stream-json propriu. Effort: M (human ~3 zile / CC ~2-3h) Risk: Med Pros: - Zero dependențe noi; controlezi fiecare linie - Rămâne sync — `send_message` și `route_message` nu se schimbă - Arhitectură deja validată în producție pe LXC 171 Cons: - Reimplementează parserul stream-json (DRY vs `_run_claude`) - Reimplementează ce oferă SDK-ul oficial; risc de obsolescență - Fără `interrupt()` — ar trebui construit separat Reuses: _get_session_lock, _safe_env, build_system_prompt, sentinela __AUDIO__ APPROACH B: Coadă + coalescing (minimal viable) Summary: Mesajul 2 se acumulează și pleacă ca prompt separat după turul 1. Effort: S (human ~4h / CC ~20min) Risk: Low Pros: - Diff minim, zero procese noi, zero RAM în plus - Nu atinge deloc calea de rate limit Cons: - NU rezolvă problema — corecția ajunge tot după ce Claude a greșit - Respins explicit de utilizator la întrebarea de arhitectură Reuses: lock-ul existent ELIMINAT: nu satisface cerința. APPROACH C: claude-agent-sdk (ideal architecture) Summary: `ClaudeSDKClient` din pachetul oficial `claude-agent-sdk` (v0.2.151). `client.query(async_generator)` livrează mesaje mid-tur; `client.interrupt()` oprește turul. Autentificare pe credențialele CLI — compatibil cu abonamentul, fără API key. Effort: M (human ~3 zile / CC ~2-3h) — comparabil cu A Risk: Med Pros: - Steering ȘI interrupt out of the box; interrupt e o capabilitate pe care A nu o are - Framing stream-json, ciclul de viață al procesului și sesiunile sunt întreținute upstream - Zero cod de parsare de întreținut; imun la schimbări de format ale CLI-ului Cons: - API async-only (`async with ClaudeSDKClient()`) — reintroduce granița sync/async pe care planul a evitat-o intenționat; `route_message` e sync cu multe căi de comenzi rapide - Dependență nouă pe un pachet 0.2.x cu 151 de release-uri — suprafață de churn - Maparea `--system-prompt` / `--dangerously-skip-permissions` / allowed_tools pe opțiunile SDK trebuie verificată empiric Reuses: build_system_prompt, sessions/active.json, sentinela __AUDIO__ ``` **RECOMMENDATION: A. Alternativa C e ELIMINATĂ pe licențiere, nu pe merit tehnic.** Documentația Agent SDK (`code.claude.com/docs/en/agent-sdk/overview`) conține nota: > *„Unless previously approved, Anthropic does not allow third party developers to offer > claude.ai login or rate limits for their products, including agents built on the Claude > Agent SDK. Use the API key authentication methods described in the Quickstart instead."* Plus: „Use of the Claude Agent SDK is governed by Anthropic's Commercial Terms of Service." Mecanic SDK-ul chiar poate porni pe credențialele CLI-ului, dar direcția politicii e API key — iar constrângerea utilizatorului e explicită: **doar abonament, fără API**. C e închisă. Aceeași pagină recomandă, pentru orice limbaj în afara SDK-urilor Python/TS: *„run the CLI as a subprocess with the `-p` flag"* — exact ce face echo-core azi și ce extinde varianta A. Calea actuală e cea documentată, nu un ocol. **Clasificare: MECHANICAL, nu taste.** Constrângerea utilizatorului elimină singura alternativă concurentă. A se implementează cu corecțiile E2 (parser partajat) și E3 (autocompact) care erau oricum îmbunătățiri de sine stătătoare, plus E1 (cancel) — capabilitatea pe care C ar fi adus-o gratis și pe care acum trebuie s-o construim explicit. E1 devine astfel mai valoros, nu mai puțin. ## 0D. SELECTIVE EXPANSION **Complexity check:** planul atinge 6 fișiere (`claude_runner.py` nou, `claude_session.py`, `router.py`, 3 adaptoare) + teste. Sub pragul de 8 — fără miros de complexitate. Două clase noi (`ClaudeProcess`, `RunnerRegistry`) — la limita de 2, acceptabil. **Minimum set care atinge scopul:** etapele 1-3. Etapele 4-6 sunt hardening, nu funcționalitate — dar 4 (rate limit) e **obligatorie**, nu opțională: fără ea o cale existentă se rupe tăcut. **Scan de expansiune (candidați, auto-decis):** | # | Oportunitate | Efort | Decizie | Principiu | |---|---|---|---|---| | E1 | **Cancel/stop turn** (`/stop`) — oprește un tur scăpat de sub control | S (CC ~20min) | **ACCEPTAT în scope** | P2 — în raza de acțiune (același `ClaudeProcess`), <1 zi. Odată ce ai proces persistent, `proc.stop()` există deja; expunerea lui e trivială și e capabilitatea vecină cea mai valoroasă | | E2 | **Parser stream-json partajat** între `_run_claude` și runner | S (CC ~15min) | **ACCEPTAT în scope** | P4 DRY — altfel două parsere diverg | | E3 | **`--autocompact auto`** la spawn | XS | **ACCEPTAT în scope** | P1 completeness — fără el sesiunile lungi mor pe context | | E4 | Placeholder editabil (un mesaj care se actualizează, ca în bridge) în loc de stream de mesaje noi | M | **DEFER → TODOS.md** | În afara razei — schimbă UX-ul tuturor răspunsurilor, nu doar al celor steered | | E5 | Steering cu atașamente (poză trimisă mid-tur) | M | **DEFER → TODOS.md** | Adaptoarele salvează deja atașamente ca `[ATTACHMENT:path]`; merge, dar e scope nou | | E6 | Procesele vii expuse în dashboard (`workspace.html`) | S | **SKIP** | `eco status` (etapa 5) acoperă nevoia; dashboard-ul ar fi duplicare | **Scope acceptat:** E1 (cancel), E2 (parser partajat), E3 (autocompact). **Deferat la TODOS.md:** E4, E5. **Respins:** E6. ## 0E. Temporal Interrogation ``` HOUR 1 (fundații): Ce formă are dict-ul întors de run_turn? → identic cu _run_claude, altfel se rupe start_session/resume_session. Unde stă parserul comun? → funcție la nivel de modul în claude_session.py, importată de runner. HOUR 2-3 (logică): Ce se întâmplă dacă steering-ul prinde procesul între `result` și următorul tur? → fallback la tur normal. Ce se întâmplă cu `on_text` în timpul unui tur steered? → blocurile continuă să curgă; utilizatorul vede două fire de text. DECIZIE NECESARĂ (vezi P4). HOUR 4-5 (integrare): Rate limit-ul nu mai vine ca exit code. Unde se detectează? → în parser, pe `result.is_error` + textul. Trebuie să ridice ACELAȘI RuntimeError ca azi, altfel `_local_fallback_reply` nu mai pornește. Voice și text partajează channel_id — un turn voice mid-tur text devine steering. Dorit sau nu? DECIZIE NECESARĂ. HOUR 6+ (polish): Fake `claude` pentru teste trebuie să vorbească stream-json pe stdout ȘI să citească stdin — altfel testul de steering nu e testabil. `/clear` trebuie să omoare procesul viu, nu doar să șteargă intrarea JSON. ``` ## 0F. Mode Selection **SELECTIVE EXPANSION** confirmat (feature enhancement pe sistem existent). Abordarea aleasă sub acest mod: **A + corecțiile E2/E3 din C**, cu E1 (cancel) adăugat în scope. ## Constrângere confirmată de utilizator (2026-09-02) **Doar abonament Claude Pro/Max — fără API Anthropic, fără API key.** Ambele abordări viabile respectă asta: A folosește CLI-ul `claude` ca subprocess (ca azi); C (`claude-agent-sdk`) se autentifică pe **credențialele CLI-ului**, nu pe API key — documentația SDK-ului o confirmă explicit. Niciun apel la `api.anthropic.com` din cod propriu. ## Measurement: RAM per proces claude viu Măsurat pe această mașină (`ps -eo rss`): **292 MB și 541 MB** pentru două procese `claude` vii. Mașina are 8192 MB total, ~5750 MB disponibili. `max_live=4` × ~400 MB = ~1,6 GB ținuți permanent doar în procese inactive. **Decizie (auto, P3 pragmatic): `steering.max_live` implicit 2, nu 4.** Marius vorbește pe 1-2 canale simultan în practică; 4 e dimensionat pentru un scenariu care nu există. ## Section 1: Architecture Review **Graf de dependențe — înainte / după:** ``` ÎNAINTE DUPĂ adapters ──▶ router ──▶ claude_session adapters ──▶ router ──▶ claude_session │ │ ├──▶ _run_claude (one-shot) └──▶ _run_claude ──▶ Popen │ │ ▲ (moare la finalul turului) │ │ └── heartbeat, planning_session │ └──▶ claude_runner ──▶ ClaudeProcess (viu) heartbeat ─────┐ │ │ planning ──────┴──▶ _run_claude │ └──▶ RunnerRegistry (+reaper thread) └──▶ _parse_stream() ◀── PARTAJAT (E2) ``` Cuplare nouă: `claude_runner` → `claude_session` pentru parser + `build_system_prompt` + `_safe_env`. Unidirecțională, justificată. **Fără import circular**: runner importă din session, session importă runner *lazy* (în corpul lui `send_message`), nu la nivel de modul. **Flux de date — patru căi, pentru `steer()`:** ``` mesaj ──▶ [inflight?] ──▶ wrap [EXTERNAL CONTENT] ──▶ json.dumps ──▶ stdin.write ──▶ flush │ │ │ │ ▼ ▼ ▼ ▼ nil: text=None → TypeError fără wrap = GAUR non-UTF8 → BrokenPipeError GAP: guard explicit DE SECURITATE json.dumps (proces mort între (vezi Section 3) escapează OK check și write) empty: text="" → tur gol, GAP: prinde și cazi Claude răspunde aiurea pe tur normal GAP: sari peste steering error: procesul a murit → BrokenPipeError → fallback la tur normal (respawn cu --resume) ``` **Mașină de stare `ClaudeProcess`:** ``` ┌──────────┐ start() ┌─────────┐ run_turn() ┌──────────┐ │ DEAD │───────────▶│ ALIVE │─────────────▶│ INFLIGHT │ └──────────┘ │ idle │◀─────────────└──────────┘ ▲ └─────────┘ result │ │ │ │ steer() ── permis DOAR aici │ stop() / reaper / │ │ │ crash / model change │ │ └───────────────────────┴─────────────────────────┘ timeout → stop() → DEAD Tranziții imposibile și ce le previne: DEAD ──steer()──▶ X : `if not alive: raise TurnFailed` în send() INFLIGHT ──reaper──▶ X : reaper sare peste orice proces inflight INFLIGHT ──run_turn()──▶ X: lock-ul per canal serializează tururile ``` **Findings:** | # | Finding | Severitate | Decizie (auto) | Principiu | |---|---|---|---|---| | A1 | **Fără shutdown handler.** `main.py` rulează adaptoarele sub `asyncio.gather`. La `systemctl restart` / SIGTERM, procesele `claude` vii rămân orfane (500 MB fiecare). Bridge-ul are `stop_all()`; planul nu-l menționează. | **HIGH** | Adaugă `RunnerRegistry.stop_all()` legat de shutdown-ul din `main.py` + `atexit` | P1 completeness | | A2 | **`max_live` atins cu toate procesele inflight** — LRU nu poate opri un proces inflight, deci fie depășești plafonul, fie blochezi. Nedefinit în plan. | MED | Degradare grațioasă: canal nou peste plafon → cade pe calea one-shot existentă (`resume_session`), nu blochează | P1 completeness | | A3 | **Reaper-ul e SPOF.** Dacă thread-ul moare pe o excepție, procesele se acumulează la infinit, tăcut. | MED | Bucla prinde orice excepție și continuă (tiparul din `runner.py:_reaper_loop`) + o linie de log la fiecare reap | P1 + lecția „turnul nu se pierde niciodată" | | A4 | **Rollback incomplet.** Flag-ul off nu omoară procesele deja vii — rămân zombie până la reaper. | MED | Flip-ul flagului declanșează `stop_all()`; documentat în etapa 6 | P1 | | A5 | Registry-ul e stare globală mutabilă la nivel de modul (ca `_session_locks`) | LOW | Acceptat — tipar existent. Testele au nevoie de fixture care golește registry-ul, ca `_clear_session_locks` | P3 pragmatic | **Scaling:** rupe primul RAM-ul (2 procese × ~400 MB), nu CPU. La 10x canale active, plafonul `max_live=2` forțează degradarea la one-shot — comportament corect, nu prăbușire. **SPOF:** reaper-ul (A3) și bridge-ul WhatsApp (preexistent, nu introdus aici). **Rollback:** `steering.enabled=false` + restart serviciu. Sub 30 secunde. Cu A4 rezolvat, și fără restart. ## Section 2: Error & Rescue Map ``` METODĂ/CODEPATH | CE POATE MERGE PROST | CLASĂ EXCEPȚIE -----------------------------|-----------------------------------|-------------------- ClaudeProcess.start() | binarul claude lipsește | FileNotFoundError | spawn eșuează (fd-uri epuizate) | OSError ClaudeProcess.send()/steer() | procesul a murit între check/write| BrokenPipeError | stdin închis | ValueError ClaudeProcess.run_turn() | turul depășește timeout | TimeoutError | stdout EOF fără `result` | RuntimeError | linie JSON coruptă | json.JSONDecodeError | RATE LIMIT în result.is_error | RuntimeError ← NOU | pipe stderr plin → deadlock | (blocare, fără excepție) RunnerRegistry.reap_once() | stop() aruncă pe un proces mort | ProcessLookupError _parse_stream() | bloc text fără cheia 'text' | KeyError CLASĂ EXCEPȚIE | PRINSĂ? | ACȚIUNE | UTILIZATORUL VEDE ------------------------|---------|----------------------------------|--------------------------- FileNotFoundError | DA | ridică mesajul de instalare | „Claude CLI not found" | | (identic cu _run_claude:306) | OSError la spawn | NU ← GAP| — | traceback brut ← RĂU BrokenPipeError | NU ← GAP| — | steering pierdut tăcut ← CRITIC TimeoutError | DA | stop() + ridică, ACELAȘI mesaj | „Claude CLI timed out after Ns" | | ca azi | RuntimeError (EOF) | DA | stop() + stderr tail în mesaj | „Claude CLI error: …" json.JSONDecodeError | DA | `continue` (ca _run_claude:347) | nimic (transparent) RuntimeError RATE LIMIT | NU ← GAP| — | **fallback-ul local NU pornește** ← CRITIC deadlock stderr | NU ← GAP| — | turul îngheață până la timeout ← CRITIC ProcessLookupError | NU ← GAP| — | reaper-ul moare → scurgere procese KeyError în parser | NU ← GAP| — | turul cade pe un bloc malformat ``` **Cele patru GAP-uri critice și acțiunea de reparare:** 1. **RATE LIMIT.** `is_rate_limit_error` potrivește pe textul `"hit your …limit"` într-un `RuntimeError`. Cu proces persistent, limita vine ca linie `result` cu `is_error: true`. Parserul trebuie să ridice **exact** `RuntimeError(f"Claude CLI error (exit 1): {detail}")` cu textul limitei, ca `router.py:611` să-l prindă și `_local_fallback_reply` să pornească. Fără asta, cea mai muncită cale din repo moare tăcut. 2. **BrokenPipeError la steer.** Prinde-l → marchează procesul mort → **refă mesajul ca tur normal**. Nu-l lăsa să se piardă (lecția explicită din CLAUDE.md: „turnul nu se pierde niciodată"). 3. **Deadlock stderr.** Thread dedicat de drenare, obligatoriu. Fără el un tur cu mult stderr blochează procesul fără nicio excepție. 4. **ProcessLookupError în reaper.** `contextlib.suppress` în jurul lui `stop()`, buclă care nu moare. **Regulă respectată:** niciun `except Exception` fără re-raise. Singura excepție permisă e bucla reaper-ului, unde înghițirea e intenționată — dar cu log. ## Section 3: Security & Threat Model | # | Amenințare | Probabilitate | Impact | Mitigat de plan? | |---|---|---|---|---| | S1 | **Mesajul steered ocolește wrapping-ul `[EXTERNAL CONTENT]`.** `start_session:489` și `resume_session:563` împachetează fiecare mesaj între markeri, iar system prompt-ul (`:451-453`) instruiește explicit să nu se supună instrucțiunilor dinăuntru. Dacă `steer()` scrie textul brut pe stdin, **protecția la injecție dispare exact pentru mesajele mid-tur** — și acelea sosesc când Claude e deja în mijlocul unei acțiuni cu unelte. | MED | **HIGH** | **NU — GAP.** `steer()` trebuie să folosească același wrapping. Ne-negociabil. | | S2 | Proces persistent cu `--dangerously-skip-permissions` viu până la 20 min după ultimul mesaj | LOW | MED | Parțial. Raza de acțiune a unei injecții persistă între tururi — dar și azi persistă prin `--resume`. Marginal nou: fereastra de timp. `idle_reap_s` o mărginește. Acceptat. | | S3 | Escapare JSON pe stdin (text cu ghilimele/newline rupe protocolul) | LOW | MED | DA — `json.dumps(ensure_ascii=False)`, ca în `runner.py:user_message` | | S4 | Secrete în argv-ul procesului persistent (vizibile în `ps`) | LOW | MED | Neschimbat față de azi — `--system-prompt` e deja în argv. Nu regresează, dar merită notat: procesul e vizibil în `ps` mai mult timp. | | S5 | Comenzi (`/clear`, `/model`) interpretate ca steering | LOW | LOW | DA — `route_message` rutează comenzile înainte de `send_message` | **Suprafață de atac nouă:** zero endpoint-uri noi, zero input-uri de rețea noi. Singurul canal nou e stdin-ul unui proces local. **S1 e singurul finding real și e o regresie, nu un risc teoretic.** ## Section 4: Data Flow & Interaction Edge Cases ``` INTERACȚIUNE | EDGE CASE | TRATAT? | CUM ------------------------------|----------------------------------|---------|--------------------------- Mesaj în timpul unui tur | procesul moare între check/write | GAP | → fallback tur normal (S2 §2) | text gol | GAP | → sari peste steering | 2 mesaje în 100 ms | DA | _stdin_lock serializează | atașament mid-tur | DEFER | E5 → TODOS.md Comandă în timpul unui tur | /clear pe canal cu proces viu | GAP | → clear_session trebuie | | | să cheme registry.stop() | /model pe canal cu proces viu | GAP | → set_session_model trebuie | | | să oprească procesul Turn voice în timpul unui | voice și text partajează | GAP | DECIZIE NECESARĂ — vezi Gate tur text (același channel_id) | session_key=channel_id | | Reaper vs tur | reap în timp ce turul e inflight | DA | skip pe inflight Restart serviciu | procese orfane | GAP | A1 → stop_all() Flip flag on→off | procese vii rămân | GAP | A4 → stop_all() ``` Cinci GAP-uri, toate cu fix specificat. Cel voice e singurul care cere o decizie umană, nu un fix. ## Section 5: Code Quality Review * **DRY:** o singură violare reală — parserul stream-json (E2, deja acceptat în scope). `_run_claude:335-370` și bucla de consum din runner ar fi 90% identice. * **Numire:** `ClaudeProcess` / `RunnerRegistry` urmează bridge-ul; consistent. `steer()` e numit după ce face, nu cum. Bine. * **Over-engineering:** `RunnerRegistry` cu reaper + LRU + plafon pentru maximum 2 procese e la limită. Justificat de A1/A3 (scurgere de procese = 500 MB fiecare), nu de scală. * **Under-engineering:** `steer()` fără guard pe text gol și fără wrapping (S1) — fragil pe calea fericită. * **Complexitate ciclomatică:** `send_message` devine dispecer cu ~4 ramuri (steering on/off × inflight/nu × plafon atins). Sub 5. Acceptabil, dar extrage `_try_steer(channel_id, text) -> bool` ca funcție separată în loc să umfli `send_message`. * Niciun `except Exception` nou în afara buclei reaper-ului. ## Section 6: Test Review ``` FLUXURI UX NOI: - mesaj trimis în timpul unui tur → reacție ➡️, fără text - /stop în timpul unui tur (E1) FLUXURI DE DATE NOI: - text → wrap EXTERNAL CONTENT → json → stdin → model (mid-tur) - stdout stream-json → parser partajat → dict identic cu _run_claude CODEPATHS NOI: - steering.enabled on/off - inflight / not inflight - plafon max_live atins → degradare one-shot - respawn cu --resume după moartea procesului - reap pe inactivitate JOBURI ASINCRONE NOI: - thread drenare stderr (per proces) - thread reaper (unul global) INTEGRĂRI NOI: - proces claude persistent cu stdin deschis CĂI DE EROARE NOI: - rate limit din result.is_error (NU din exit code) - BrokenPipeError la steer - timeout tur → stop() → aceeași excepție ca azi ``` | # | Test | Tip | Există în plan? | |---|---|---|---| | T1 | Tur normal prin proces persistent → dict identic cu `_run_claude` | Unit (fake claude) | Da | | T2 | Steering mid-tur ajunge pe stdin | Unit (fake claude care citește stdin) | Da | | T3 | Respawn cu `--resume` după kill | Unit | Da | | T4 | Reaper nu omoară un proces inflight | Unit | Da | | T5 | Timeout → `TimeoutError` cu **exact** mesajul de azi | Unit | Da | | T6 | **Rate limit din stream ridică RuntimeError pe care `is_rate_limit_error` îl prinde** | Unit | Da (etapa 4) | | T7 | **`_local_fallback_reply` chiar pornește pe rate limit din proces persistent** | Integration | **LIPSEȘTE — critic.** T6 testează detecția, nu lanțul complet. Ăsta e testul care contează. | | T8 | **`steer()` împachetează în `[EXTERNAL CONTENT]`** | Unit | **LIPSEȘTE — S1** | | T9 | **BrokenPipe la steer → mesajul se refă ca tur normal** | Unit | **LIPSEȘTE** | | T10 | Suita existentă trece cu `steering.enabled=false` | Regression | Da (etapa 2) | | T11 | `test_claude_session_mutex.py` trece nemodificat | Regression | Implicit — **fă-l explicit**, e testul care pinează contractul lock-ului | | T12 | `/clear` omoară procesul viu | Unit | **LIPSEȘTE** | | T13 | `stop_all()` la shutdown, zero orfani | Unit | **LIPSEȘTE — A1** | **Testul pentru 2 dimineața vineri:** T7. Dacă fallback-ul local nu mai pornește, Marius rămâne fără asistent la limită de rate și nimeni nu află până nu se întâmplă. **Ce ar scrie un QA ostil:** trimite 5 mesaje în 200 ms în timpul unui tur, apoi `/clear`, apoi încă unul. **Chaos test:** `kill -9` pe procesul `claude` la mijlocul unui tur, verifică respawn cu `--resume` și că sesiunea continuă. **Flakiness:** T2 și T4 depind de timing. Fake-ul `claude` trebuie să semnalizeze prin fișier/pipe, nu prin `sleep`. **LLM/eval:** planul nu atinge `personality/*.md` și nici prompt-ul fallback-ului local. Nicio suită de eval nu trebuie rulată. (Dacă etapa 4 ajunge să atingă `build_system_prompt`, atunci da.) ## Section 7: Performance Review Fără DB, fără query-uri, fără N+1 — secțiunea are o singură dimensiune reală: **memoria**. Măsurat: 292-541 MB RSS per proces `claude`. Cu `max_live=2` → până la ~1 GB rezident. Mașina are 5750 MB disponibili, deci încape, dar nu e neglijabil pe un host care rulează și Ollama. Câștig de latență: turul 2+ pe un canal sare spawn-ul (~2-4 s din spike-uri), deci steering-ul e și o optimizare de latență, nu doar o funcționalitate. Presiune pe pool-uri de conexiuni: zero. Fără alte findings. ## Section 8: Observability & Debuggability Review | Ce | Există azi | Nevoie nouă | |---|---|---| | Log per tur | `_invoke_log` cu channel/model/durată/tokens (`:513`) | Adaugă `steered=N` la linia turului | | Log per steering | — | **Obligatoriu.** O linie per steer: canal, lungime text, dacă a reușit. Fără ea, „nu a ținut cont de mesajul meu" e nediagnosticabil | | Fiecare `return`/eșec tăcut | Lecția din CLAUDE.md: fiecare `return None` din `_local_fallback_reply` e logat | Aplică aceeași regulă: **fiecare cale prin care un mesaj NU devine steering se loghează** | | Procese vii | — | `eco status` (etapa 5): câte procese, pe ce canale, de cât timp inactive | | Reaper | — | Log la fiecare proces oprit, cu motivul | | stderr | Citit la final în `_run_claude:377` | Buffer circular 50 linii, expus în `eco doctor` | **Debuggabilitate la 3 săptămâni:** cu logurile de mai sus, da. Fără logul de steering, nu. ## Section 9: Deployment & Rollout Review Fără migrări DB, fără schimbări de schemă. `config.json` are `reload()` (`config.py:24`), deci flagul poate fi citit la cald — dar procesele deja vii nu se opresc singure (A4). ``` Etapa 1-2 (flag off) ──▶ deploy, zero comportament schimbat ──▶ suita verde │ │ │ rollback: nimic de făcut, calea nu e activă │ ▼ ▼ Etapa 3-5 (flag off) ──▶ deploy incremental ──────────────▶ teste + eco status │ ▼ Etapa 6: flip pe UN canal ──▶ dogfood 2-3 zile ──▶ flip global │ │ │ rollback: steering.enabled=false + stop_all() (<30s) ▼ Verificare primele 5 min: trimite 2 mesaje suprapuse, confirmă ➡️ și un singur răspuns coerent Verificare prima oră: `eco status` arată procese care se sting după idle_reap_s ``` **Fereastră de risc la deploy:** `systemctl --user restart echo-core` cu procese vii → orfani (A1). Fix-ul A1 e prerechizit pentru etapa 6, nu opțional. ## Section 10: Long-Term Trajectory Review * **Datorie tehnică introdusă:** un parser stream-json și un manager de procese proprii, de întreținut la fiecare schimbare de format al CLI-ului. E2 (parser partajat) o înjumătățește; nu o elimină. * **Path dependency:** dacă `claude-agent-sdk` devine calea normală (probabil — e Claude Code ca bibliotecă, întreținut upstream), varianta A devine cod aruncat. Nu blochează nimic, dar e ~300 de linii care ar putea fi zero. * **Reversibilitate: 4/5.** Flag + un modul nou; `_run_claude` rămâne intact ca plasă. Nu e ușă cu sens unic. * **Ce vine după:** interrupt/cancel (E1, deja în scope), atașamente mid-tur (E5), placeholder editabil (E4). Arhitectura A le suportă pe toate — `ClaudeProcess` e locul potrivit. * **Potențial de platformă:** dacă runner-ul devine stratul unic de sesiune, `planning_session.py` și Ralph l-ar putea folosi. Nu în acest plan, dar arhitectura nu-l exclude. * **Întrebarea de la 1 an:** un inginer nou citește `claude_runner.py` și înțelege de ce există două căi (persistent vs one-shot)? **Numai dacă docstring-ul modulului o spune explicit.** Cere-l: de ce heartbeat/planning rămân one-shot. ## Section 11: Design & UX Review **SĂRIT** — zero scope UI. Planul nu introduce ecrane, formulare sau componente; singurul element vizibil e o reacție emoji pe un mesaj existent, în API-uri de chat care o suportă nativ. Verificat prin grep pe termeni de UI: singurele potriviri au fost substringuri românești (*informat*, *reformul*), zero button/modal/screen/dialog. ## Required Outputs — Phase 1 ### NOT in scope | Item | De ce e deferat | |---|---| | Placeholder editabil în loc de stream de mesaje noi (E4) | Schimbă UX-ul **tuturor** răspunsurilor, nu doar al celor steered. Scope propriu. → TODOS.md | | Steering cu atașamente (E5) | Adaptoarele salvează deja `[ATTACHMENT:path]`; merge, dar e funcționalitate nouă, nu parte din steering. → TODOS.md | | Procese vii în dashboard (E6) | `eco status` acoperă nevoia; dashboard-ul ar fi duplicare. Respins. | | Migrarea heartbeat / planning / Ralph pe proces persistent | Decizie explicită a utilizatorului. Niciun câștig — nu există om care să facă steering pe un job de noapte. | | `claude-agent-sdk` (alternativa C) | Eliminată pe licențiere: docs direcționează spre API key, iar constrângerea e „doar abonament". | | Compactare proprie / management de context | `--autocompact auto` (T8) delegă asta CLI-ului. A construi ceva propriu ar fi ocean, nu lac. | ### What already exists Vezi tabelul complet din **0B**. Rezumat: 7 din 9 sub-probleme au deja cod în repo care se reutilizează (`_get_session_lock`, `_load_sessions`, `_safe_env`, `build_system_prompt`, sentinela `__AUDIO__:`, reacțiile pe toate trei adaptoarele, detecția de rate limit). Singurele lucruri chiar noi sunt **bucla de proces persistent** și **scrierea pe stdin**. Restul e integrare, nu construcție. Corecție importantă față de planul inițial: **reacțiile există deja pe toate trei adaptoarele** (`add_reaction` / `set_message_reaction` / `react_whatsapp`), deci etapa 3 e mai mică decât estimat și nu are nevoie de cercetare pe bridge-ul Baileys. ### Failure Modes Registry ``` CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES | LOGGED? --------------------|-------------------------|----------|-------|------------------|-------- steer() | proces mort (BrokenPipe) | T7 | T7 | răspuns normal | T12 steer() | fără wrap injecție | T1 | T1 | — | — steer() | text gol | T1 | T1 | nimic (sări) | T12 run_turn() | rate limit în stream | T2 | T6 | fallback local | da run_turn() | timeout | plan | T5* | mesaj identic azi| da run_turn() | EOF fără result | plan | da | „Claude CLI err" | da stderr pipe | plin → deadlock | T3 | T3 | tur înghețat | da reaper | excepție → thread mort | T11 | T11 | scurgere tăcută | T11 registry | max_live + toate inflight| T10 | T10 | one-shot (lent) | T10 shutdown | procese orfane | T4 | T13* | — | T4 clear_session | proces rămas viu | T9 | T9 | model stale | T9 ``` `T5*`/`T13*` = numerotare din Section 6 (teste), restul din Implementation Tasks. **Zero rânduri CRITICAL GAP rămase** — fiecare RESCUED=N din analiza inițială are acum un task. ### Dream state delta Planul + expansiunile acceptate (E1 cancel, E2 parser partajat, E3 autocompact) duc echo-core de la „mesajul 2 așteaptă" la „mesajul 2 ajunge, și pot opri un tur scăpat". Rămâne la ~70% din idealul de 12 luni. Nerezolvate: decizia voice-vs-text pe același canal, atașamente mid-tur, un strat de sesiune partajat cu Ralph/planning. ### Scope Expansion Decisions * **Acceptate:** E1 (cancel `/stop`), E2 (parser stream-json partajat), E3 (`--autocompact auto`) * **Deferate la TODOS.md:** E4 (placeholder editabil), E5 (atașamente mid-tur) * **Respinse:** E6 (procese vii în dashboard — duplicare peste `eco status`) ### Stale Diagram Audit `CLAUDE.md` § Arhitectură descrie fluxul ca `Adapter → router.py → claude_session.py → Claude CLI`. Rămâne corect, dar **incomplet** după acest plan — trebuie adăugat `claude_runner.py` și explicat de ce există două căi. Diagrama nu e greșită, e trunchiată. Task: T15 (docstring) + o linie în CLAUDE.md. Alte diagrame ASCII în fișierele atinse: niciuna. ## Implementation Tasks Sintetizate din findings-urile de mai sus. Fiecare derivă dintr-un finding specific. - [ ] **T1 (P1, human: ~2h / CC: ~15min)** — claude_runner — Împachetează mesajele steered în `[EXTERNAL CONTENT]` - Surfaced by: Section 3 S1 — text brut pe stdin ocolește protecția la injecție exact pentru mesajele mid-tur - Files: `src/claude_runner.py`, `tests/test_claude_runner.py` - Verify: `pytest tests/test_claude_runner.py -k external_content` - [ ] **T2 (P1, human: ~3h / CC: ~20min)** — claude_session — Ridică același `RuntimeError` la rate limit din `result.is_error` - Surfaced by: Section 2 — procesul persistent nu iese cu cod ≠ 0, deci `_local_fallback_reply` n-ar mai porni niciodată - Files: `src/claude_session.py`, `src/claude_runner.py` - Verify: `pytest tests/test_local_fallback.py` - [ ] **T3 (P1, human: ~1h / CC: ~10min)** — claude_runner — Thread dedicat de drenare stderr, deque mărginit - Surfaced by: Section 2 — pipe-ul plin blochează procesul fără nicio excepție - Files: `src/claude_runner.py` - Verify: test cu fake claude care scrie >64KB pe stderr - [ ] **T4 (P1, human: ~2h / CC: ~15min)** — main — `stop_all()` la shutdown și `atexit` - Surfaced by: Section 1 A1 — `systemctl restart` lasă orfani de ~500 MB - Files: `src/main.py`, `src/claude_runner.py` - Verify: pornește, creează proces, `SIGTERM`, `pgrep -f claude` gol - [ ] **T5 (P1, human: ~1h / CC: ~10min)** — claude_session — Extrage parserul stream-json partajat - Surfaced by: Section 5 + 0B — singura violare DRY reală din plan - Files: `src/claude_session.py`, `src/claude_runner.py` - Verify: `pytest tests/test_claude_session.py` - [ ] **T6 (P1, human: ~3h / CC: ~20min)** — tests — Integration: rate limit prin proces persistent pornește chiar fallback-ul - Surfaced by: Section 6 T7 — testul de 2 dimineața vineri - Files: `tests/test_local_fallback.py` - Verify: `pytest tests/test_local_fallback.py -k persistent` - [ ] **T7 (P1, human: ~1h / CC: ~10min)** — claude_runner — `BrokenPipeError` la steer → refă ca tur normal - Surfaced by: Section 2 gap 2 + lecția „turnul nu se pierde niciodată" - Files: `src/claude_runner.py`, `tests/test_claude_runner.py` - Verify: test care omoară procesul între check și write - [ ] **T8 (P2, human: ~30min / CC: ~5min)** — claude_runner — `--autocompact auto` la spawn - Surfaced by: 0A P7 — sesiunea persistentă crește monoton spre limita de context - Files: `src/claude_runner.py` - Verify: `build_cmd` conține flagul - [ ] **T9 (P2, human: ~1h / CC: ~10min)** — claude_session — `/clear` și `/model` opresc procesul viu - Surfaced by: Section 4 — altfel procesul rămâne cu config stale - Files: `src/claude_session.py` - Verify: `pytest tests/test_claude_runner.py -k clear` - [ ] **T10 (P2, human: ~2h / CC: ~15min)** — claude_runner — Degradare la one-shot când `max_live` e atins cu toate inflight - Surfaced by: Section 1 A2 — LRU nu poate opri un proces inflight - Files: `src/claude_runner.py` - Verify: test cu `max_live=1` și două canale - [ ] **T11 (P2, human: ~1h / CC: ~10min)** — claude_runner — Reaper rezistent la excepții + log per reap - Surfaced by: Section 1 A3 — reaper mort = scurgere tăcută - Files: `src/claude_runner.py` - Verify: test care face `stop()` să arunce - [ ] **T12 (P2, human: ~30min / CC: ~5min)** — claude_runner — Loghează fiecare cale prin care un mesaj NU devine steering - Surfaced by: Section 8 — altfel „nu a ținut cont de mesajul meu" e nediagnosticabil - Files: `src/claude_runner.py`, `src/claude_session.py` - Verify: inspecție manuală a logurilor - [ ] **T13 (P2, human: ~3h / CC: ~20min)** — claude_runner — Comandă `/stop` legată la `ClaudeProcess.stop()` (E1) - Surfaced by: 0D E1 — capabilitatea vecină cea mai valoroasă odată ce ai proces persistent - Files: `src/claude_runner.py`, `src/router.py` - Verify: `/stop` în timpul unui tur oprește procesul, canalul rămâne utilizabil - [ ] **T14 (P2, human: ~30min / CC: ~5min)** — config — `steering.max_live` implicit 2 - Surfaced by: Section 7 — măsurat 292-541 MB RSS per proces pe host de 8 GB - Files: `config.json`, `src/claude_runner.py` - Verify: default-ul din cod e 2 - [ ] **T15 (P3, human: ~1h / CC: ~10min)** — claude_runner — Docstring: de ce heartbeat/planning rămân one-shot - Surfaced by: Section 10 — întrebarea de la 12 luni - Files: `src/claude_runner.py`, `CLAUDE.md` - Verify: citire --- # /autoplan — Phase 2: Design Review **SĂRIT — zero scope UI.** Detectat în Phase 0: singurele potriviri pe termeni de UI au fost substringuri românești (*informat*, *reformul*), zero button/modal/screen/dialog/layout. Feature-ul nu introduce nicio suprafață vizuală; singurul element vizibil e o reacție emoji pe un mesaj existent, în API-uri de chat care o suportă nativ. --- # /autoplan — Phase 2.5: DX Review Mod: **DX POLISH**. Voci: `[subagent-only]`. ## Step 0: DX Scope Assessment **Tip de produs:** feature intern într-un asistent personal self-hosted. Nu e un produs public. **Cine e „developer"-ul aici — două persoane foarte diferite:** | Persona | Cine | Ce are nevoie | |---|---|---| | **Operatorul** (unic) | Marius — pornește serviciul, citește logurile, dă flip la flag | Să vadă că merge. Să oprească rapid când nu merge. | | ~~Mentenatorul autonom~~ | ~~Ralph~~ — **confirmat oprit** (toate joburile `enabled=False`, `approved-tasks.json` gol, nimic în crontab) | n/a | O singură persona, deci DX aici înseamnă strict **experiență de operare**, nu de integrare. **DX completeness inițial: 4/10.** Planul descrie mecanica, nu experiența de operare: nu spune forma blocului de config, nu spune cum confirmi că merge, nu spune ce se schimbă în `CLAUDE.md`. ## Developer Journey (9 etape) | Etapă | Azi | Cu planul | Fricțiune | |---|---|---|---| | Discover | — | § în CLAUDE.md | **GAP — planul nu prevede update la CLAUDE.md** | | Evaluate | — | citește planul | ok | | Install | — | `git pull` | zero — fără dependențe noi | | Configure | — | `"steering": {"enabled": true}` în config.json | **GAP — forma blocului nespecificată** | | Hello world | — | restart + 2 mesaje suprapuse | **GAP — greu de produs un tur lent la comandă** | | Integrate | — | automat pe toate 3 adaptoarele | ok | | Debug | — | loguri (T12) | ok dacă T12 se face | | Upgrade | — | cheie nouă, default off | ok — sigur în ambele sensuri | | Scale | — | `max_live` | **GAP — `max_live=0` nedefinit** | ## Empathy Narrative > *„Am dat pull, am pus `steering.enabled: true`, am restartat. Acum… cum verific că merge? > Trebuie să-i dau ceva de lucru lung, apoi să scriu repede al doilea mesaj și să mă uit după o > săgeată. Dacă nu apare săgeata — e stins flagul? E plafonul atins? A murit procesul? Nu știu > care din trei. Și `eco status` nu-mi spune nimic despre asta."* Asta e problema centrală de DX: **turnul feature-ului e invizibil când nu funcționează**, iar verificarea lui cere să fabrici un tur lent la comandă. ## Pass 1: Getting Started — 6/10 TTHW: ~3 min (edit config → restart → 2 mesaje). Tier **Competitive**, nu Champion. Ce ar fi 10: `eco status` arată `steering: on · 0 procese vii`, iar `eco doctor` verifică că binarul suportă `--input-format stream-json`. Atunci confirmi în 20 de secunde, fără să fabrici un tur lent. **Finding D1 (HIGH):** nicio cale de a confirma că steering-ul e pornit și sănătos fără să-l provoci. **Fix:** `eco status` + o verificare în `eco doctor`. ## Pass 2: API/CLI Design — 8/10 `steering.enabled` respectă convenția existentă (`local_fallback.enabled`, `heartbeat.enabled`) — namespace-dict cu cheie `enabled`, citit prin dot-notation. Consistent, ghicibil. `steer()` / `run_turn()` / `RunnerRegistry` urmează numele din bridge; `steer` spune ce face. **Finding D2 (MED):** planul nu scrie blocul de config concret. Cere-l explicit în plan: ```json "steering": { "enabled": false, "idle_reap_s": 1200, "max_live": 2 } ``` **Finding D3 (MED):** `max_live: 0` și `idle_reap_s: 0` sunt nedefinite. `0` la `max_live` citit naiv înseamnă „niciun proces permis" = steering mort tăcut, cu flagul aparent pornit — cel mai prost mod de eșec posibil. **Fix:** `max_live < 1` → tratează ca `enabled: false` **și loghează**; `idle_reap_s` cu prag minim de 60s. ## Pass 3: Error Messages & Debugging — 5/10 Precedent bun deja în repo: `router.py:298` loghează `„Local fallback requested but local_fallback.enabled is false"` — problemă + cauză într-o linie. Steering-ul are nevoie de aceeași disciplină, și e exact ce cere T12. **Finding D4 (HIGH):** un mesaj care nu devine steering arată **identic** cu unul care devine — utilizatorul vede un răspuns normal în ambele cazuri. Fără log per cale, „n-a ținut cont de mine" e nediagnosticabil post-factum. Ăsta e chiar tiparul de eșec pe care CLAUDE.md îl documentează pentru fallback-ul local (2026-08-23: „detecția a mers, fallback-ul a întors None fără nicio linie de log, imposibil de diagnosticat"). **Fix:** T12, ridicat de la P2 la **P1**. ## Pass 4: Documentation & Learning — 3/10 **Finding D5 (LOW — retrogradat).** Inițial marcat CRITICAL: `CLAUDE.md:219` spune că Ralph nu atinge `src/router.py` / `src/claude_session.py`, iar `src/claude_runner.py` — fișier nou — n-ar fi pe listă. **Verificat: Ralph e oprit.** Toate joburile din `cron/jobs.json` (`night-execute`, `evening-report`, `morning-report`, ambele `*-coaching`) au `enabled=False`, `approved-tasks.json` e gol, crontab-ul de sistem n-are nimic. Nu există agent nesupravegheat care să rescrie modulul. **Fix (ieftin, nu urgent):** dacă Ralph se repornește vreodată, adaugă `src/claude_runner.py` la lista de la linia 219. Până atunci, nu blochează nimic. **Observație de repo (nu e parte din acest plan).** `CLAUDE.md` dedică ~80 de linii sistemului Ralph — comenzi, tabel de fișiere, flow de aprobare — pentru un sistem care e integral dezactivat. Documentația asta e acum înșelătoare: m-a făcut să ridic D5 la CRITICAL pe o premisă falsă, și va induce în eroare orice viitoare sesiune la fel. Merită curățată sau marcată „inactiv", dar e o schimbare separată — o semnalez, n-o fac aici. **Finding D6 (MED):** `CLAUDE.md` § Arhitectură descrie un singur flux (`Adapter → router → claude_session → CLI`). După plan există **două** căi și niciun cititor viitor nu va ghici de ce. Cere o subsecțiune scurtă: ce merge persistent, ce rămâne one-shot, **de ce**. ## Pass 5: Upgrade & Migration Path — 9/10 Cheie de config nouă cu default off: pull fără config → nimic nu se schimbă. Downgrade cu cheia prezentă → cheia e ignorată. Sigur în ambele sensuri, fără migrare, fără codemod. Singura notă: dacă flip-ul flagului nu declanșează `stop_all()` (task T4/A4), un downgrade la cald lasă procese vii. Acoperit deja. ## Pass 6: Developer Environment & Tooling — 6/10 Fără dependențe noi, fără build. Testele au nevoie de un fake `claude` care **citește stdin** și vorbește stream-json — mai greu decât fake-urile existente (care doar scriu pe stdout). **Finding D7 (MED):** fără acel fake, testul de steering nu e scriibil. E prerechizit pentru T1/T7, nu o notă de subsol. Bridge-ul are `tests/fake_claude.py` ca referință de tipar. ## Pass 7: Community & Ecosystem — N/A Repo privat, un utilizator, un agent. Nu se aplică; nescoruit ca să nu falsifice media. ## Pass 8: DX Measurement & Feedback Loops — 4/10 Nicio metrică propusă. `_invoke_log` numără tururile; nimic nu numără steering-urile. **Finding D8 (LOW):** adaugă `steered=N` la linia de log a turului (deja în T12) — atunci poți răspunde peste o lună la „chiar folosesc funcția asta?" fără să ghicești. ## DX Scorecard ``` Pass Scor Ce lipsește pentru 10 --------------------------------- ----- ---------------------------------------- 1. Getting Started 6/10 eco status + eco doctor (D1) 2. API/CLI Design 8/10 blocul de config scris (D2), max_live=0 (D3) 3. Error Messages & Debugging 5/10 log per cale ratată (D4) — ridicat la P1 4. Documentation & Learning 3/10 regula Ralph (D5) + două-căi în CLAUDE.md (D6) 5. Upgrade & Migration 9/10 — 6. Dev Environment & Tooling 6/10 fake claude care citește stdin (D7) 7. Community & Ecosystem N/A repo privat 8. DX Measurement 4/10 contor de steering (D8) --------------------------------- ----- OVERALL (7 scorate) 6.1/10 ``` **TTHW: ~3 min → țintă < 1 min** (cu `eco status` care confirmă starea fără să provoci un tur lent). ## DX Implementation Checklist - [ ] ~~**D5** lista protejată a lui Ralph~~ — retrogradat LOW: Ralph e confirmat oprit - [ ] **D4** ridică T12 (log per cale ratată) de la P2 la **P1** - [ ] **D1** `eco status`: `steering: on/off · N procese vii`; `eco doctor`: verifică `--input-format stream-json` - [ ] **D2** scrie blocul concret de config în plan și în `CLAUDE.md` - [ ] **D3** `max_live < 1` → dezactivat **cu log**; `idle_reap_s` cu prag minim 60s - [ ] **D6** subsecțiune în `CLAUDE.md`: două căi, care e care, de ce - [ ] **D7** `tests/fake_claude.py` care citește stdin — prerechizit pentru T1/T7 - [ ] **D8** `steered=N` pe linia de log a turului --- # /autoplan — Phase 3: Eng Review (gate final, pe planul amendat) Voci: `[subagent-only]`. ## Step 0: Scope Challenge (pe cod real, nu pe descriere) Planul atinge 6 fișiere + teste, adaugă 2 clase. Sub pragurile de complexitate. Verificat pe cod: `send_message` (`claude_session.py:611`) e singurul punct de intrare care se schimbă, iar `_run_claude`/`start_session`/`resume_session` rămân intacte pentru `heartbeat.py:416` și `planning_session.py:264`. Separarea e reală, nu declarativă. **Scope-ul e corect calibrat. Nicio reducere recomandată.** Singura adăugire acceptată (E1/`/stop`) e ~20 de linii peste infrastructura care oricum se construiește. ## Section 1: Architecture Graf, mașină de stare și căile de eroare sunt deja diagramate în Phase 1 § Section 1. Nu le repet. Ce adaugă faza de inginerie sunt **cursele**, care nu erau acoperite. ### Concurență — analiza care lipsea din plan Modelul de threading propus: thread A (worker `asyncio.to_thread`) rulează `run_turn()` și citește stdout blocant; thread B (alt worker) cheamă `steer()` și scrie pe stdin; thread C (reaper) poate chema `stop()`. Trei fire pe același obiect. ``` Thread A (tur) Thread B (steer) Thread C (reaper) ────────────── ──────────────── ───────────────── inflight = True stdout.readline() ──┐ │ │ read inflight → True │ │ │ result primit │ │ ← FEREASTRA DE CURSĂ inflight = False │ │ │ │ stdin.write() ──▶ pipe posibil închis (reaper eligibil) │ BrokenPipeError │ │ └──────────────────────────┴──▶ T7: refă ca tur normal ``` | # | Cursă | Severitate | Fix | |---|---|---|---| | **E-C1** | **TOCTOU pe `inflight`.** B citește `inflight=True`, A termină turul și `inflight=False`, apoi B scrie. Scrierea nimerește un proces care nu mai e în tur → linia devine turul următor, dar utilizatorul a primit deja ➡️ fără text. | **HIGH** | `steer()` face **check + write sub același `_stdin_lock`**, iar `run_turn()` setează `inflight=False` **tot sub acel lock**. Atunci fereastra dispare; T7 rămâne plasă pentru moartea reală a procesului, nu pentru cursă. | | **E-C2** | **Reaper vs steer.** Reaper-ul sare peste procesele `inflight`, dar un proces viu-și-inactiv poate fi oprit exact în timpul unui `steer()` de pe calea de fallback. | MED | `stop()` ia și el `_stdin_lock` înainte să închidă stdin | | **E-C3** | **`_stdin_lock` ținut peste I/O de rețea.** Dacă `drain`/`flush` blochează (pipe plin fiindcă procesul nu citește), lock-ul se ține la nesfârșit și blochează și `stop()`. | MED | Scrierile pe stdin sunt mici (o linie JSON) și pipe-ul are 64 KB buffer — practic nu blochează. Documentează invariantul: **niciodată I/O lung sub `_stdin_lock`**. | | **E-C4** | `stderr` citit de un al patrulea thread per proces | LOW | Deja acoperit (T3). Fără interacțiune cu celelalte lock-uri. | ### Contractul lock-ului per canal se schimbă — și planul nu o spune `send_message` ține azi lock-ul pe tot turul. Steering-ul trebuie **să NU ia lock-ul** — altfel se auto-blochează, pentru că lock-ul e deja ținut de turul în zbor. Planul spune corect „fără lock". Dar consecința nu e menționată: **contractul pe care `test_claude_session_mutex.py` îl pinează explicit devine condiționat de flag.** Docstring-ul acelui test spune: *„This test pins that behavior so future refactors must preserve it."* Cu steering pornit, comportamentul „al doilea apel așteaptă" **nu se mai păstrează** — al doilea apel face steering și se întoarce imediat. Asta e schimbarea intenționată, dar contractul trebuie rescris, nu încălcat tăcut. **E-C5 (HIGH) — test verde fals.** Testul patch-uiește `claude_session._run_claude`. Dacă `send_message` rutează spre runner când steering-ul e pornit, `_run_claude` nu mai e chemat deloc, `concurrent_seen` nu se setează niciodată și testul **trece degeaba** — verde fără să testeze nimic. **Fix:** testul de mutex forțează explicit `steering.enabled=False` (nu se bazează pe default), plus un test nou care pinează contractul cu steering pornit: al doilea apel întoarce `__STEERED__` fără să deschidă un tur. ## Section 2: Code Quality * **Dispecerul:** extrage `_try_steer(channel_id, text) -> bool` din `send_message` (deja notat în Phase 1 § Section 5). `send_message` rămâne sub 5 ramuri. * **Import lazy:** `claude_session` importă `claude_runner` **în corpul funcției**, nu la nivel de modul, fiindcă runner-ul importă parserul din session. Altfel ciclu la import. Scrie-o ca și comentariu, nu ca folclor. * **DRY:** o singură violare (parserul), deja task T5. * Fără abstracții premature: nu introduce o interfață `Backend` cu două implementări pentru două căi. `if steering: ... else: ...` explicit e mai lizibil (P5). ## Section 3: Test Review Diagrama completă e în Phase 1 § Section 6. Faza de inginerie adaugă: | # | Test lipsă | De ce | |---|---|---| | **E-T1** | Mutex-ul cu `steering.enabled=False` **forțat explicit** | E-C5 — altfel verde fals | | **E-T2** | Contract nou: cu steering pornit, al 2-lea apel întoarce `__STEERED__` fără tur nou | Contractul care înlocuiește pinul vechi | | **E-T3** | Cursă: 20 de iterații care fac steer exact la granița `result` | E-C1 — o cursă netestată e o cursă care se întoarce | | **E-T4** | `sessions/active.json` primește sid-ul **actualizat după fiecare tur**, nu doar la start | La respawn cu `--resume`, `system/init` poate întoarce un sid nou; dacă nu-l persiști, următorul respawn reia o sesiune moartă | | **E-T5** | Timeout distruge procesul → turul următor se reia curat cu `--resume` | Timeout-ul acum aruncă un proces cald; verifică că nu pierde sesiunea | **E-T4 e un bug latent, nu doar un test lipsă.** Planul spune „persistă în `sessions/active.json`", dar nu spune **când**. Bridge-ul actualizează `sid` în `finally`-ul fiecărui tur (`bot.py:974`). Dacă echo-core îl scrie doar la primul tur, un respawn ulterior folosește un sid vechi. → **Task nou E1 (P1).** ## Section 4: Performance Măsurătoarea e în Phase 1 § Section 7 (292-541 MB RSS, `max_live=2`). Adaug o observație care lipsea: **timeout-ul devine mai scump.** Azi un timeout omoară un proces care oricum murea. Cu proces persistent, un timeout aruncă un proces cald, iar turul următor plătește respawn (~2-4 s) **plus** reîncărcarea integrală a istoricului prin `--resume`. Pe o sesiune lungă asta nu e neglijabil. Nu schimbă designul — dar `TURN_TIMEOUT` nu trebuie coborât „ca să fie sigur". `_safe_env()` se evaluează la spawn: o rotire de credențiale în keyring **nu ajunge** la un proces viu până la respawn. Minor, dar notabil pentru `/otp` — dacă tokenul roa2web se reînnoiește, procesul viu are env-ul vechi. Verifică dacă `roa2web_client` citește keyring-ul la runtime (atunci e irelevant) sau env-ul de la pornire (atunci e un bug). ## Section 5: Security Acoperit în Phase 1 § Section 3. Singurul finding real rămâne **S1** (wrapping `[EXTERNAL CONTENT]` la steer, task T1) — o regresie de securitate, nu un risc teoretic. Confirmat pe cod: `start_session:489` și `resume_session:563` împachetează; `steer()` trebuie să facă la fel. Fără suprafață de rețea nouă. `--dangerously-skip-permissions` e neschimbat față de azi, doar fereastra de viață a procesului crește — mărginită de `idle_reap_s`. ## Section 6: Hidden Complexity Ce arată simplu în plan și nu este: 1. **„`run_turn()` întoarce același dict"** — pare o formalitate. De fapt e contractul care ține `start_session`/`resume_session`/`_invoke_log`/`sessions/active.json` nemodificate. Orice câmp lipsă se manifestă ca `KeyError` la runtime, într-un thread, la un tur oarecare. Merită un test de formă (assert pe setul de chei), nu doar teste de comportament. 2. **„Reacție ➡️"** — pare cosmetic, dar înseamnă că adaptorul trebuie să distingă `__STEERED__` de un răspuns real **în trei locuri diferite**, fiecare cu API propriu de reacții. Trei fișiere, trei tipare, trei moduri de a greși. 3. **„Flag implicit off"** — nu e gratuit: fiecare test nou trebuie să-l seteze explicit în ambele sensuri, altfel testezi calea greșită fără să afli (E-C5). 4. **Reaper-ul** — 30 de linii care, greșite, scurg 500 MB pe oră tăcut. --- # /autoplan — Voci independente și corecții Codex indisponibil (binar neinstalat) → toate fazele `[subagent-only]`. ## CEO DUAL VOICES — CONSENSUS TABLE ``` ═══════════════════════════════════════════════════════════════ Dimensiune Claude Codex Consensus ───────────────────────────────────── ─────── ────── ───────── 1. Premise valide? NO N/A flagged 2. Problema potrivită? PARTIAL N/A flagged 3. Scope calibrat corect? NO N/A flagged 4. Alternative explorate suficient? NO N/A flagged 5. Riscuri competitive acoperite? PARTIAL N/A flagged 6. Traiectorie la 6 luni solidă? PARTIAL N/A flagged ═══════════════════════════════════════════════════════════════ Codex lipsă ⇒ nicio dimensiune nu poate fi CONFIRMED. Findings critice dintr-o singură voce se ridică oricum. ``` ## DX DUAL VOICES — CONSENSUS TABLE ``` ═══════════════════════════════════════════════════════════════ Dimensiune Claude Codex Consensus ───────────────────────────────────── ─────── ────── ───────── 1. Getting started < 5 min? NO N/A flagged 2. Denumiri API/CLI ghicibile? PARTIAL N/A flagged 3. Mesaje de eroare acționabile? PARTIAL N/A flagged 4. Docs găsibile și complete? NO N/A flagged 5. Cale de upgrade sigură? YES N/A — 6. Mediu de dezvoltare fără fricțiune? YES N/A — ═══════════════════════════════════════════════════════════════ Scoruri DX voce independentă: getting started 5, API 6, erori 6, docs 4, upgrade 7, mediu 8, măsurare 3. TTHW: ~15 min ca scris → ~3 min cu fix-urile. ``` ## Corecții factuale la planul original (verificate pe cod) ### ❌ Riscul #2 era INVERSAT — se elimină Planul original spunea: *„`--system-prompt` se fixează la spawn. Azi se reconstruiește din `personality/*.md` la fiecare mesaj."* **Fals, verificat:** `build_system_prompt()` e chemat în exact două locuri — `claude_session.py:486` (`start_session`) și `scheduler.py:402`. **`resume_session` nu pasează deloc `--system-prompt`** (liniile 565-571). Deci editările din `personality/` **deja** nu se aplică mid-sesiune; se aplică doar la o sesiune nouă, adică după `/clear`. Fix-ul propus (hash pe fișiere → respawn) ar fi fost **comportament nou**, care ar omorî procesul viu și steering-urile în așteptare de fiecare dată când Marius editează `SOUL.md` cu Echo pornit. **Decizie: riscul se șterge, fix-ul nu se implementează.** Reîncărcarea personalității rămâne legată de `/clear`, exact ca azi. ### ❌ Citarea implementării de referință era ambiguă `~/workspace/romfastsql/proxmox/lxc171-claude-agent/` **local** conține doar README + scripturi — clona locală e stale. Codul citat (`discord-bridge/runner.py`, `bot.py:652-663`) există pe **LXC 171**, accesat prin `ssh echo@10.0.20.201 "sudo pct exec 171 -- cat …"`. Citarea trebuie să numească gazda, altfel implementatorul caută local și nu găsește nimic. ### ❌ „Flagul poate fi citit la cald" era fals `claude_session.py:142` face `ALLOWED_TOOLS = _load_allowed_tools()` **la nivel de modul**, citind `config.json` la import, și modulul **nu importă deloc `src.config`**. Dacă steering copiază tiparul, flagul e citit o singură dată la pornire, iar rollback-ul cere restart. **Fix:** citește `steering.*` prin `Config()` în `router.py` (care deține deja `_get_config()`), **per apel**, nu la import. ### ❌ Etapa 6 cerea o cheie inexistentă `channels.echo-core` are doar `['id','default_model']`. „Flip pe UN canal" n-are pe ce să se sprijine. **Fix:** adaugă suport pentru `channels..steering: true` care are prioritate peste flagul global. ## Amendamente acceptate din vocile independente | # | Amendament | Sursă | Decizie | Principiu | |---|---|---|---|---| | **X1** | **Contractul de rate limit se mută din Etapa 4 în Etapa 1.** Etapa 2 nu se merge-uiește până testul nu e verde. | CEO#3 **și** DX#5 — **temă transversală** | **ACCEPTAT** | P1 — altfel Etapele 2-3 livrează cu fallback-ul local mort tăcut | | **X2** | `steer()` întoarce enum `STEERED / RAN_AS_TURN / PROCESS_DEAD`, nu bool; dispecerul deține fallback-ul | CEO#4 | **ACCEPTAT** | P5 explicit — „cade înapoi pe tur normal" era afirmat, nu proiectat | | **X3** | `idle_reap_s` → **`idle_minutes`** (convenția repo: `interval_minutes`, `auto_leave_minutes`) | DX#1 | **ACCEPTAT** | P4 DRY/consistență | | **X4** | Blocul de config **se livrează în `config.json`** la Etapa 2, nu doar ca default în cod | DX#4 | **ACCEPTAT** | P1 — altfel pornirea înseamnă scris JSON de mână dintr-un doc | | **X5** | **Reacția care eșuează nu lasă tăcere** — wrap pe `add_reaction`; la eroare, un ack text de o linie | DX#3 | **ACCEPTAT** | „turnul nu se pierde niciodată", aplicat confirmării. Bridge-ul WhatsApp are istoric de deconectare tăcută | | **X6** | **Renunță la evicția LRU.** Păstrează `max_live` ca număr + degradare la one-shot; scoate evicția. | CEO#8 | **ACCEPTAT** | P3+P5 — LRU care evacuează canalul în care tocmai scrii e o suprafață de concurență fără beneficiu la N=2 | | **X7** | `max_live <= 0` → steering oprit **cu log**; `idle_minutes < 1` → clamp la 1 | DX#6 | **ACCEPTAT** | P1 — altfel steering mort tăcut cu flagul aparent pornit | | **X8** | Kill switch de mediu **`ECHO_STEERING=off`** (precedent: `CLAUDE_BIN = os.environ.get(...)`) | DX#6 | **ACCEPTAT** | P1 — oprire fără să editezi JSON versionat | | **X9** | **Re-spike** cu system prompt real + tur care lansează un subagent + corecție *blândă* (nu „STOP, zi doar ANANAS") | CEO#7 | **ACCEPTAT, înainte de Etapa 1** | P1 — spike-ul curent dovedește mecanismul, nu cazul de folosire | | **X10** | **Pinează versiunea CLI + assert pe forma frame-ului** în testul cu fake claude | CEO (nota wire-format) | **ACCEPTAT** | `--output-format stream-json` e transport intern, nu contract de stabilitate; CLI e la 2.1.258 | | **X11** | Ack-ul poartă conținut: `➡️ prins: ` | CEO#9 | **ACCEPTAT** | La un tur de 3 minute, ➡️ urmat de tăcere e nedistinsabil de „ignorat" → invită o a doua corecție → dublu steer | | **X12** | Documentează cele două invariante (forma dict-ului; forma erorii de rate limit) — **T15 urcă la P1** | DX#5 | **ACCEPTAT** | P1 — sunt invariantele care țin fallback-ul local în viață | | **X13** | Costează explicit alternativele B (coalescing, ~15 linii) și „abort+restart" (~40 linii) într-un paragraf fiecare | CEO#10 | **ACCEPTAT** | P6 — dacă motivul real e preferința lui Marius, se consemnează ca preferință | ## Ridicat la Final Gate, NU auto-decis **CEO#1 — „Livrează `/stop` singur întâi, apoi decide dacă mai vrei steering."** Argumentul: durerea declarată e „face ceva greșit și nu-l pot opri". Azi **nu există niciun abort** (doar SIGINT pe tot procesul, `main.py:116`). A omorî `Popen` din `_run_claude` e muncă de o după-amiază. Planul cumpără ~600 de linii de concurență pe proces persistent ca să obțină corecție-târzie, iar abort-ul iese doar ca produs secundar. Nu e auto-decis: **re-secvențiază direcția pe care ai ales-o explicit.** O singură voce (Codex indisponibil), deci nu se califică drept User Challenge formal — dar e cel mai important punct strategic din tot review-ul. Vezi Gate. --- ## Decision Audit Trail | # | Fază | Decizie | Clasificare | Principiu | Motiv | Respins | |---|---|---|---|---|---|---| | 1 | CEO 0F | Mod SELECTIVE EXPANSION | Mechanical | default contextual | feature enhancement pe sistem existent | EXPANSION, HOLD, REDUCTION | | 2 | CEO 0C-bis | Abordarea A (port hand-rolled) | Mechanical | constrângere utilizator | C închisă pe licențiere (abonament, fără API); B respinsă deja de utilizator | B, C | | 3 | CEO 0D | E1 `/stop` acceptat în scope | Taste | P2 boil lakes | în raza de acțiune, <1 zi CC, capabilitatea vecină cea mai valoroasă | defer | | 4 | CEO 0D | E2 parser partajat acceptat | Mechanical | P4 DRY | singura violare DRY reală din plan | defer | | 5 | CEO 0D | E3 `--autocompact auto` acceptat | Mechanical | P1 completeness | fără el sesiunile lungi mor pe context | defer | | 6 | CEO 0D | E4 placeholder editabil → TODOS | Mechanical | P2 rază de acțiune | schimbă UX-ul tuturor răspunsurilor, nu doar al celor steered | accept | | 7 | CEO 0D | E5 atașamente mid-tur → TODOS | Mechanical | P2 rază de acțiune | funcționalitate nouă, nu parte din steering | accept | | 8 | CEO 0D | E6 dashboard procese vii RESPINS | Mechanical | P4 DRY | `eco status` acoperă nevoia | accept, defer | | 9 | CEO S3 | T1 wrapping `[EXTERNAL CONTENT]` la steer | Mechanical | P1 completeness | regresie de securitate, nu risc teoretic | omitere | | 10 | CEO S7 | `max_live` 4 → 2 | Mechanical | P3 pragmatic | măsurat 292-541 MB RSS pe host de 8 GB | păstrare 4 | | 11 | DX P3 | T12 (log per cale ratată) P2 → P1 | Mechanical | P1 completeness | tiparul de eșec din 2026-08-23, deja în CLAUDE.md | păstrare P2 | | 12 | DX P4 | D5 (listă Ralph) CRITICAL → LOW | Mechanical | dovadă | verificat: toate joburile Ralph `enabled=False` | păstrare CRITICAL | | 13 | Eng | E-C1 check+write sub un singur `_stdin_lock` | Mechanical | P1 completeness | TOCTOU pe `inflight` produce ➡️ fără text | doar T7 ca plasă | | 14 | Eng | E3/E4 contract de mutex rescris | Mechanical | P1 completeness | testul ar trece vacuu (verde fals) | lăsare ca e | | 15 | Voci | X1 rate limit Etapa 4 → Etapa 1 | Mechanical | **temă transversală** CEO#3 + DX#5 | altfel Etapele 2-3 livrează cu fallback-ul local mort | păstrare Etapa 4 | | 16 | Voci | X2 `steer()` întoarce enum, nu bool | Mechanical | P5 explicit | fallback-ul era afirmat, nu proiectat | bool | | 17 | Voci | X3 `idle_reap_s` → `idle_minutes` | Mechanical | P4 consistență | convenția repo: `*_minutes` | păstrare `_s` | | 18 | Voci | X4 config în `config.json` + citit per apel | Mechanical | P1 | `ALLOWED_TOOLS` la import e capcana | default doar în cod | | 19 | Voci | X5 reacție eșuată → ack text | Mechanical | P1 | „turnul nu se pierde niciodată", aplicat confirmării | tăcere | | 20 | Voci | X6 renunță la evicția LRU | Mechanical | P3+P5 | suprafață de concurență fără beneficiu la N=2 | păstrare LRU | | 21 | Voci | X9 re-spike cu tur realist | Mechanical | P1 | spike-ul dovedește mecanismul, nu cazul | mers pe spike-ul curent | | 22 | Voci | X11 ack cu conținut (`➡️ prins: …`) | Taste | P1 | ➡️ + 2 min tăcere ≡ „ignorat" → dublu steer | ➡️ simplu | | 23 | Voci | X15 Riscul #2 ȘTERS | Mechanical | **dovadă pe cod** | `resume_session` nu pasează `--system-prompt`; fix-ul ar fi fost o regresie | păstrare risc + hash | | 24 | Voci | CEO#1 („`/stop` întâi") **NU auto-decis** | **Ridicat la Gate** | — | re-secvențiază direcția aleasă explicit de utilizator | auto-accept, auto-respingere | **Total: 24 decizii — 21 auto-decise (20 mechanical, 2 taste), 1 ridicată la Gate.** ## ENG DUAL VOICES — CONSENSUS TABLE ``` ═══════════════════════════════════════════════════════════════ Dimensiune Claude Codex Consensus ───────────────────────────────────── ─────── ────── ───────── 1. Arhitectură solidă? YES* N/A flagged 2. Acoperire de teste suficientă? NO N/A flagged 3. Riscuri de performanță tratate? YES N/A — 4. Amenințări de securitate acoperite? PARTIAL N/A flagged 5. Căi de eroare tratate? NO N/A flagged 6. Risc de deployment gestionabil? YES N/A — ═══════════════════════════════════════════════════════════════ * YES condiționat de E1 (check+write sub un singur _stdin_lock) și X2 (enum). Fără ele, arhitectura are o cursă TOCTOU nedocumentată. Vocea eng independentă a rulat pe planul amendat; findings-urile ei majore (concurență, contract de mutex) coincid cu analiza primară — vezi § Eng Review. ``` ## Completion Summary ``` +====================================================================+ | /autoplan — Steering în echo-core | +====================================================================+ | Mod: SELECTIVE EXPANSION | | Voci: [subagent-only] — Codex neinstalat | | Faze rulate: CEO ✓ · Design SĂRIT (fără UI) · DX ✓ · Eng ✓ | +--------------------------------------------------------------------+ | Decizii: 24 total — 21 auto · 2 taste · 1 la Gate | | Task-uri: 49 (21 P1 · 22 P2 · 6 P3) | | Premise: 7 verificate → 2 CONFIRMATE false, 1 ELIMINATĂ | | Corecții 4 afirmații din plan infirmate pe cod: | | factuale: · Riscul #2 (system-prompt) — inversat, șters | | · „un singur răspuns la final" — deja fals | | · „flag citit la cald" — fals (import-time) | | · „WhatsApp poate n-are reacții" — are | +--------------------------------------------------------------------+ | Scor DX: 6.1/10 TTHW ~15 min → ~3 min cu fix-urile | | Gap-uri critice rămase: 0 (fiecare are task) | | Reversibilitate: 4/5 — flag + modul nou, _run_claude intact | +====================================================================+ ``` ## Unresolved Decisions 1. **Secvențierea `/stop` vs steering** (CEO#1) — vezi Final Gate. 2. **Voice și text pe același `channel_id`** — un turn voice în timpul unui tur text devine steering. Nedecis; nu blochează Etapa 1-2 (voice-ul e pe alt adaptor, dar același `session_key`). ## Blocante găsite de vocea de inginerie (verificate pe cod) ### C1 — Turul orfan. Proprietatea pe stdout se rotește între fire. **CRITIC** Ordinea care contează e **în interiorul CLI-ului**, nu în procesul nostru: CLI-ul poate emite `result` *înainte* ca scrierea ta să ajungă, în timp ce `run_turn` e între citirea acelei linii și achiziția lock-ului. **Niciun lock nu închide o cursă peste un pipe.** Consecința: `RAN_AS_TURN` e valoarea *periculoasă* din enum, nu cea sigură. Înseamnă că scrierea a aterizat și a devenit turul N+1, **al cărui stdout nu-l deține nimeni**. Dacă dispecerul răspunde la `RAN_AS_TURN` chemând `run_turn(text)` din nou → dublu-trimis, apoi citești evenimentele orfanului împotriva promptului greșit: răspuns greșit la mesajul greșit, facturat de două ori, `active.json` derapat. **Fix acceptat (P5 explicit, diff mic):** `RAN_AS_TURN` înseamnă **„consumă stream-ul pe care tocmai l-ai pornit"**, niciodată „reîncearcă". **Upgrade robust, notat ca alternativă:** un thread cititor permanent care deține stdout și împinge tururile complete într-un `queue.Queue`; `run_turn` face pop. Atunci orfanii sunt pop-uiți și logați, nu corup turul următor. → **decizie de gust, vezi Gate.** ### C2 — Voice și text partajează `channel_id`. **CRITIC** Verificat: `voice/pipeline.py:429-430` cheamă `_route_message(str(self.text_channel_id), …)` cu `adapter_name="discord-voice"` — **același id** ca textul (`router.py:589`, `session_key = channel_id`). Iar `pipeline.py:444` oglindește `response_text` înapoi în canalul text. Deci: un mesaj text care face steering pe un tur voice viu primește răspunsul **rostit cu voce** și niciodată randat ca text; iar un `__STEERED__` întors pe calea voice ajunge **postat literal** în canal. **`src/voice/pipeline.py` lipsește complet din lista de fișiere a planului („3 adaptoare").** **Fix:** refuză steering-ul când adaptorul turului în zbor diferă de cel al mesajului sosit (cade pe lock, comportament de azi) **și** tratează sentinela pe calea voice. `voice/pipeline.py` intră în lista de fișiere atinse — sunt 7, nu 6. ### C3 — Mesajul steered se pierde la orice eșec mid-tur. **CRITIC** `router.py:611` prinde excepția și, la rate limit, cheamă `_local_fallback_reply(text)` cu **textul mesajului 1**. Firul mesajului 2 s-a întors deja cu `STEERED`. Rate limit în timpul unui steer = pierdere tăcută — exact tiparul pe care CLAUDE.md îl fixează ca „turnul nu se pierde niciodată". **Fix:** `ClaudeProcess` reține textele steered ale turului curent; la eșec le re-dispecerizează. T6 trebuie să testeze **livrarea textului steered**, nu doar detecția limitei. ### C4 — Parserul partajat + ridicarea la rate limit ar regresa `PlanningSession`. **HIGH** Verificat: `claude_session.py:412-415` comentează explicit *„Surface subtype/is_error for callers that retry on `error_max_turns` (PlanningSession does this)"*, iar `planning_session.py:74` are `RETRY_MAX_TURNS = 30 # boost on error_max_turns`. `_run_claude` **nu aruncă** intenționat pe `is_error` — planning-ul depinde de asta. **Fix:** parserul rămâne **pur** (parse → dict, nu aruncă niciodată). Ridicarea se face doar în wrapper-ul din runner, condiționată pe `is_rate_limit_error(detail)`. Task T5 se modifică în consecință. ### H1 — Verificarea `inflight` e rasă și în cazul obișnuit. **HIGH → simplificare** Două mesaje la ~50 ms fără niciun tur pornit: ambele văd „not inflight", mesajul 2 se blochează pe lock pentru tot turul, iar steering-ul **nu se întâmplă tăcut** — exact cazul QA-ului ostil. **Fix acceptat (P5+P3):** folosește `lock.acquire(blocking=False)` la nivel de dispecer. **Eșecul de a lua lock-ul *este* „un tur rulează".** Elimină flagul `inflight` din decizia dispecerului și odată cu el întreaga cursă TOCTOU. Mai puține stări, cod mai scurt. ### H2, M1, M2 * **H2** — „`test_claude_session_mutex.py` trece nemodificat" e necesar dar înșelător: pinează doar contractul cu flag off. `TestAcquisitionBehavior` (linia 229) afirmă că un apelant în contenție se blochează apoi continuă; cu steering pornit trebuie **să facă steering**. Adaugă geamănul cu steering pornit + fixture autouse care golește registry-ul, altfel procesele fake se scurg între teste. * **M1** — degradarea la one-shot pentru un canal care are deja un proces viu inactiv dă **doi scriitori pe același `session_id`** (`--resume` din `resume_session` plus procesul viu). Oprește procesul înainte de a degrada. * **M2** — timeout → `stop()` → turul următor face `--resume` pe o sesiune omorâtă la mijlocul unui tool-call. Netestat. ### Notă de securitate suplimentară `_safe_env()` stale la spawn nu e listat în modelul de amenințări. Confirmat ca finding minor. --- # DECIZII FINALE (gate aprobat 2026-09-02) | # | Întrebare | Alegere | |---|---|---| | D3 | Secvențiere | **Ambele, dar `/stop` se livrează primul** | | D4 | Turul orfan (C1) | **Consume-the-stream** — `RAN_AS_TURN` = consumă stream-ul pornit, niciodată reîncearcă | | D5 | Aprobare review | **Aprobat ca atare** — cele 21 de decizii auto rămân aplicate | ## Etape re-secvențiate ### Etapa 0 — `/stop` singur, pe calea one-shot actuală ⟵ NOU, se livrează primul Nu atinge nimic din arhitectura de steering. `_run_claude` deja ține `proc` (`claude_session.py:310`) și un thread watchdog care îl omoară la timeout (`:322-333`) — mecanismul de kill există, îi lipsește doar un declanșator din exterior. * Registru `channel_id → Popen` pentru turul în zbor * Comandă `/stop` în `router.py` → `proc.terminate()`, apoi `kill()` după grație * Utilizatorul vede: „⏹ oprit" — nu o eroare de CLI * `sessions/active.json` rămâne valid: sesiunea supraviețuiește, doar turul moare **Efort:** human ~4h / CC ~30min. **Rulează singur o săptămână înainte de Etapa 1.** > **Cost onest al eșalonării:** registrul de PID-uri din Etapa 0 se rescrie parțial când treci pe > proces persistent — `ClaudeProcess.stop()` îl înlocuiește. Pierzi ~30 de linii. În schimb ai > oprirea în producție într-o după-amiază și afli din uz dacă mai vrei steering-ul. ### Etapele 1-6 — steering (neschimbate ca ordine, cu X1 aplicat) | # | Ce | Schimbat față de planul inițial | |---|---|---| | 1 | `claude_runner.py` + fake claude care citește stdin + **contractul de rate limit** | **X1**: rate limit urcat din Etapa 4. Etapa 2 nu se merge-uiește până T6 nu e verde | | 2 | Dispecer cu `lock.acquire(blocking=False)` (**H1**) + sentinelă + **blocul de config livrat în `config.json`** (**X4**) | H1 elimină TOCTOU-ul de dispecer; flagul citit per apel, nu la import | | 3 | Reacții pe **4** căi: Discord, Telegram, WhatsApp, **voice** (**C2**) | `voice/pipeline.py` adăugat — 7 fișiere, nu 6 | | 4 | Hardening: C3 (re-dispecerizare text steered), E2 (sid persistat per tur), M1 | Rate limit nu mai e aici — a plecat în Etapa 1 | | 5 | Reaper + `max_live` **fără LRU** (**X6**) + `eco status`/`eco doctor` (**D1**) | LRU scos ca suprafață de concurență inutilă la N=2 | | 6 | Flip pe un canal (**X16**: cheie `channels..steering`), apoi global | Cheia per-canal trebuie adăugată — nu exista | ### Config final ```json "steering": { "enabled": false, "idle_minutes": 20, "max_live": 2 } ``` Citit prin `Config()` **per apel** în `router.py`, nu la import. `max_live <= 0` → oprit cu log. `idle_minutes < 1` → clamp la 1. Kill switch: `ECHO_STEERING=off`. ### Fișiere atinse (7) `src/claude_runner.py` (nou) · `src/claude_session.py` · `src/router.py` · `src/adapters/discord_bot.py` · `src/adapters/telegram_bot.py` · `src/adapters/whatsapp.py` · **`src/voice/pipeline.py`** · plus `config.json`, `CLAUDE.md`, `tests/` ## Status **EXECUTAT 2026-09-02.** Toate etapele (0-6) implementate pe master, necommitate. Steering livrat cu `steering.enabled: false` în `config.json` și zero override-uri per canal — `/stop` e activ, steering-ul rămâne stins până la flip manual. Suită: 1199 passed / 12 failed — toate cele 12 confirmate pre-existente pe HEAD curat (worktree separat): 2 `test_claude_session` (assert stale pe system prompt), 1 `test_cli` (`assets/voice/*.wav` lipsă), 4 `test_discord` (`owned_bot.user` None în fixture), 3 `test_heartbeat`, 1 `test_dashboard_ralph_endpoint`, 1 `test_dashboard_unified_index`. `tests/test_dashboard_projects_endpoint.py` se blochează — și pe HEAD curat. Abateri față de plan, verificate pe cod: - **M1 nereproductibil** — `RunnerRegistry.get()` întoarce procesul existent al canalului înaintea oricărei verificări de capacitate, deci „doi scriitori pe același session_id" nu e accesibil dinspre registry. Garda ar fi fost cod mort; dispecerul evită cazul și pe calea C2 (așteaptă lock-ul, dar rulează prin ACELAȘI proces viu). - **session_id se ia din două locuri**, nu doar din `system`/`init`: init acoperă turul care expiră înainte de `result`, iar `result` reîmprospătează la fiecare tur reușit. - **`eco status` numără prin `pgrep -f "--input-format stream-json"`**, nu prin registry: `eco` e proces separat, deci `live_count()` ar fi fost structural mereu 0. ## Addendum — două findings din notificările finale ale vocilor ### A1 — Sentinela trebuie tratată într-un singur loc. **HIGH** Verificat: `__AUDIO__:` e tratat în **Discord (3 locuri)** și **Telegram (1)**, dar **deloc în WhatsApp**. Convenția existentă are deja o gaură — un utilizator WhatsApp care declanșează TTS primește șirul literal `__AUDIO__:/cale`. **Bug preexistent, nu introdus aici.** `__STEERED__` ar replica exact tiparul, acum pe **4 căi** (Discord, Telegram, WhatsApp, voice). **Fix:** o singură funcție `is_sentinel(response)` pe calea partajată, nu patru verificări copiate. Repară și bug-ul preexistent de pe WhatsApp ca efect secundar. ### A2 — `_safe_env()` la spawn schimbă comportamentul comutatorului OpenRouter. **HIGH** `_safe_env()` (`claude_session.py:231-249`) verifică semaforul `.use_openrouter` și, dacă există, încarcă `~/.claude-env.sh` — care setează variabilele `ANTHROPIC_*` ca să rutează prin OpenRouter. Azi asta se re-evaluează **la fiecare tur**, deci crearea sau ștergerea semaforului are efect de la mesajul următor. Cu proces persistent se evaluează **o singură dată, la spawn**: comuți providerul și **nu se întâmplă nimic** până la respawn — sau, mai rău, rămâi pe OpenRouter după ce ai șters semaforul, crezând că nu ești. Nu e „env stale, minor". E un comutator de provider care încetează tăcut să funcționeze. **Fix:** la începutul fiecărui tur, compară starea semaforului cu cea de la spawn; dacă diferă, oprește procesul și respawn-ează. Ăsta e singurul respawn-on-change justificat — spre deosebire de cel pe `personality/` (Riscul #2, șters), aici starea chiar se aplică per proces. ### A3 — Ajustări de etapizare din vocea DX * `eco status` cu `steering: on/off · N procese vii` se mută din **Etapa 5 în Etapa 2** — Etapele 2-4 sunt exact unde depanezi. * T15 primește o **rețetă de reproducere**: „ca să testezi manual, cere ceva cu un `sleep` de 30s+ în Bash, apoi trimite al doilea mesaj". Fără ea, fiecare mentenator viitor repetă spike-ul eșuat.