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
92 KiB
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.Popenone-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 unthreading.Lockper 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 propriulasyncio.to_thread(route_message, ...), deci mesajul 2 chiar ajunge însend_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— procesclaudepersistent 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ă laresult.bot.py:652-663— dacă vine un mesaj câtproc.inflighte True, nu deschide tur nou: îl scrie pe același stdin, reacționează cu ➡️,return "steered".session_idluat din evenimentulsystem/init→--resumela 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 atingesteer(text)— scrie pe stdin sub un_stdin_lock, fără să deschidă tursession_idcapturat din evenimentulsystem/init→--resumela 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
- Rate limit — cel mai subtil. Detecția de azi (
is_rate_limit_error) prinde unRuntimeErrorconstruit din exit code ≠ 0. Un proces persistent nu moare la limită — apare caresult.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. ELIMINAT — premisă inversată. Verificat:--system-promptse fixează la spawn.resume_session(claude_session.py:565-571) nu pasează deloc--system-prompt;build_system_prompt()e chemat doar la:486(start_session) șischeduler.py:402. Editările dinpersonality/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 deSOUL.md. Vezi § Corecții factuale.set_session_modeltrebuie să oprească procesul viu; turul următor îl repornește cu--resume(caset_optionsdin bridge).- RAM — fiecare proces
claudeviu costă. De aici plafonulmax_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:
- RATE LIMIT.
is_rate_limit_errorpotrivește pe textul"hit your …limit"într-unRuntimeError. Cu proces persistent, limita vine ca linieresultcuis_error: true. Parserul trebuie să ridice exactRuntimeError(f"Claude CLI error (exit 1): {detail}")cu textul limitei, carouter.py:611să-l prindă și_local_fallback_replysă pornească. Fără asta, cea mai muncită cale din repo moare tăcut. - 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ă").
- Deadlock stderr. Thread dedicat de drenare, obligatoriu. Fără el un tur cu mult stderr blochează procesul fără nicio excepție.
- ProcessLookupError în reaper.
contextlib.suppressîn jurul luistop(), 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/RunnerRegistryurmează bridge-ul; consistent.steer()e numit după ce face, nu cum. Bine. - Over-engineering:
RunnerRegistrycu 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_messagedevine dispecer cu ~4 ramuri (steering on/off × inflight/nu × plafon atins). Sub 5. Acceptabil, dar extrage_try_steer(channel_id, text) -> boolca funcție separată în loc să umflisend_message. - Niciun
except Exceptionnou î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-sdkdevine 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_clauderă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 —
ClaudeProcesse 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
RuntimeErrorla rate limit dinresult.is_error- Surfaced by: Section 2 — procesul persistent nu iese cu cod ≠ 0, deci
_local_fallback_replyn-ar mai porni niciodată - Files:
src/claude_session.py,src/claude_runner.py - Verify:
pytest tests/test_local_fallback.py
- Surfaced by: Section 2 — procesul persistent nu iese cu cod ≠ 0, deci
- 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 șiatexit- Surfaced by: Section 1 A1 —
systemctl restartlasă orfani de ~500 MB - Files:
src/main.py,src/claude_runner.py - Verify: pornește, creează proces,
SIGTERM,pgrep -f claudegol
- Surfaced by: Section 1 A1 —
- 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 —
BrokenPipeErrorla 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 autola spawn- Surfaced by: 0A P7 — sesiunea persistentă crește monoton spre limita de context
- Files:
src/claude_runner.py - Verify:
build_cmdconține flagul
- T9 (P2, human: ~1h / CC: ~10min) — claude_session —
/clearși/modelopresc 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_livee 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ă
/stoplegată laClaudeProcess.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_liveimplicit 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. |
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. Șieco statusnu-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_scu prag minim 60s - D6 subsecțiune în
CLAUDE.md: două căi, care e care, de ce - D7
tests/fake_claude.pycare citește stdin — prerechizit pentru T1/T7 - D8
steered=Npe 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) -> booldinsend_message(deja notat în Phase 1 § Section 5).send_messagerămâne sub 5 ramuri. - Import lazy:
claude_sessionimportă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ță
Backendcu 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:
- „
run_turn()întoarce același dict" — pare o formalitate. De fapt e contractul care ținestart_session/resume_session/_invoke_log/sessions/active.jsonnemodificate. Orice câmp lipsă se manifestă caKeyErrorla runtime, într-un thread, la un tur oarecare. Merită un test de formă (assert pe setul de chei), nu doar teste de comportament. - „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. - „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).
- 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
- Secvențierea
/stopvs steering (CEO#1) — vezi Final Gate. - 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șisession_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.pytrece 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(--resumedinresume_sessionplus procesul viu). Oprește procesul înainte de a degrada. - M2 — timeout →
stop()→ turul următor face--resumepe 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 → Popenpentru turul în zbor - Comandă
/stopînrouter.py→proc.terminate(), apoikill()după grație - Utilizatorul vede: „⏹ oprit" — nu o eroare de CLI
sessions/active.jsonră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 deresult, iarresultreîmprospătează la fiecare tur reușit. eco statusnumără prinpgrep -f "--input-format stream-json", nu prin registry:ecoe proces separat, decilive_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 statuscusteering: on/off · N procese viise 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
sleepde 30s+ în Bash, apoi trimite al doilea mesaj". Fără ea, fiecare mentenator viitor repetă spike-ul eșuat.