Files
echo-core/tasks/steering-plan.md
Marius Mutu 747afbaf9d feat(steering): mesaje mid-tur + /stop pe turul în zbor
Un al doilea mesaj trimis cât Claude încă lucra aștepta până se termina
turul 1 — corecția „stai, nu în master" ajungea după ce greșeala era gata.
Verificat în producție înainte de commit: mesajul 2 stătea 25s blocat în
lock, apoi pornea ca tur separat.

Acum canalele de chat pot ține un proces `claude` viu per canal, cu stdin
deschis, și al doilea mesaj intră în ACELAȘI tur.

- `src/claude_runner.py` — ClaudeProcess (steering, respawn cu --resume,
  drenare stderr, respawn la comutarea OpenRouter) + RunnerRegistry
  (max_live, reaper pe inactivitate, stop_all la shutdown)
- `src/stream_json.py` — parser stream-json partajat cu `_run_claude`;
  pur, nu aruncă niciodată pe is_error (PlanningSession retrimite pe
  error_max_turns și depinde de asta)
- `src/sentinels.py` — un singur loc pentru __AUDIO__/__STEERED__, în loc
  de 4 verificări copiate; repară și bug-ul preexistent prin care
  WhatsApp posta literal `__AUDIO__:/cale`
- dispecer în `send_message`: lock.acquire(blocking=False) — eșecul de a
  lua lock-ul ESTE „rulează un tur", ceea ce elimină flagul inflight din
  decizie și cursa TOCTOU odată cu el
- `/stop` oprește turul, nu sesiunea — active.json rămâne valid
- rate limit prin proces persistent vine ca result.is_error, nu ca exit
  code; convertit înapoi în același RuntimeError, altfel fallback-ul
  local nu s-ar mai declanșa niciodată, în tăcere

Steering-ul nu face niciodată cross-adapter (un mesaj text nu intră
într-un tur voice: împart același channel_id). Mesajele steered dintr-un
tur care pică sunt re-livrate, nu pierdute.

Testat live cu CLI-ul real: corecție la secunda 10 dintr-un tur de 24s,
un singur result, num_turns=2. Notă: mesajele steered sunt împachetate în
[EXTERNAL CONTENT], deci o corecție formulată ca override agresiv poate
fi refuzată ca prompt injection — pentru oprire folosește /stop.

Suită: 1199 passed, 12 failed (toate pre-existente pe HEAD curat).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiJGsZVSEGjRHZEJiXaxCC
2026-09-02 11:05:58 +00:00

92 KiB
Raw Blame History

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 "<mesaj>"), 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 <personality> --dangerously-skip-permissions [--resume <sid>], 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:

"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.<nume>.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: <primele ~40 caractere> 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.<nume>.steering), apoi global Cheia per-canal trebuie adăugată — nu exista

Config final

"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.