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
1376 lines
92 KiB
Markdown
1376 lines
92 KiB
Markdown
<!-- /autoplan restore point: /home/moltbot/.gstack/projects/romfast-echo-core/master-autoplan-restore-20260902-083510.md -->
|
||
# 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:
|
||
```json
|
||
"steering": { "enabled": false, "idle_reap_s": 1200, "max_live": 2 }
|
||
```
|
||
**Finding D3 (MED):** `max_live: 0` și `idle_reap_s: 0` sunt nedefinite. `0` la `max_live` citit
|
||
naiv înseamnă „niciun proces permis" = steering mort tăcut, cu flagul aparent pornit — cel mai
|
||
prost mod de eșec posibil. **Fix:** `max_live < 1` → tratează ca `enabled: false` **și loghează**;
|
||
`idle_reap_s` cu prag minim de 60s.
|
||
|
||
## Pass 3: Error Messages & Debugging — 5/10
|
||
|
||
Precedent bun deja în repo: `router.py:298` loghează `„Local fallback requested but
|
||
local_fallback.enabled is false"` — problemă + cauză într-o linie. Steering-ul are nevoie de
|
||
aceeași disciplină, și e exact ce cere T12.
|
||
**Finding D4 (HIGH):** un mesaj care nu devine steering arată **identic** cu unul care devine —
|
||
utilizatorul vede un răspuns normal în ambele cazuri. Fără log per cale, „n-a ținut cont de mine"
|
||
e nediagnosticabil post-factum. Ăsta e chiar tiparul de eșec pe care CLAUDE.md îl documentează
|
||
pentru fallback-ul local (2026-08-23: „detecția a mers, fallback-ul a întors None fără nicio linie
|
||
de log, imposibil de diagnosticat"). **Fix:** T12, ridicat de la P2 la **P1**.
|
||
|
||
## Pass 4: Documentation & Learning — 3/10
|
||
|
||
**Finding D5 (LOW — retrogradat).** Inițial marcat CRITICAL: `CLAUDE.md:219` spune că Ralph nu
|
||
atinge `src/router.py` / `src/claude_session.py`, iar `src/claude_runner.py` — fișier nou — n-ar fi
|
||
pe listă. **Verificat: Ralph e oprit.** Toate joburile din `cron/jobs.json` (`night-execute`,
|
||
`evening-report`, `morning-report`, ambele `*-coaching`) au `enabled=False`, `approved-tasks.json`
|
||
e gol, crontab-ul de sistem n-are nimic. Nu există agent nesupravegheat care să rescrie modulul.
|
||
**Fix (ieftin, nu urgent):** dacă Ralph se repornește vreodată, adaugă `src/claude_runner.py` la
|
||
lista de la linia 219. Până atunci, nu blochează nimic.
|
||
|
||
**Observație de repo (nu e parte din acest plan).** `CLAUDE.md` dedică ~80 de linii sistemului
|
||
Ralph — comenzi, tabel de fișiere, flow de aprobare — pentru un sistem care e integral dezactivat.
|
||
Documentația asta e acum înșelătoare: m-a făcut să ridic D5 la CRITICAL pe o premisă falsă, și
|
||
va induce în eroare orice viitoare sesiune la fel. Merită curățată sau marcată „inactiv",
|
||
dar e o schimbare separată — o semnalez, n-o fac aici.
|
||
|
||
**Finding D6 (MED):** `CLAUDE.md` § Arhitectură descrie un singur flux
|
||
(`Adapter → router → claude_session → CLI`). După plan există **două** căi și niciun cititor viitor
|
||
nu va ghici de ce. Cere o subsecțiune scurtă: ce merge persistent, ce rămâne one-shot, **de ce**.
|
||
|
||
## Pass 5: Upgrade & Migration Path — 9/10
|
||
|
||
Cheie de config nouă cu default off: pull fără config → nimic nu se schimbă. Downgrade cu cheia
|
||
prezentă → cheia e ignorată. Sigur în ambele sensuri, fără migrare, fără codemod.
|
||
Singura notă: dacă flip-ul flagului nu declanșează `stop_all()` (task T4/A4), un downgrade la cald
|
||
lasă procese vii. Acoperit deja.
|
||
|
||
## Pass 6: Developer Environment & Tooling — 6/10
|
||
|
||
Fără dependențe noi, fără build. Testele au nevoie de un fake `claude` care **citește stdin** și
|
||
vorbește stream-json — mai greu decât fake-urile existente (care doar scriu pe stdout).
|
||
**Finding D7 (MED):** fără acel fake, testul de steering nu e scriibil. E prerechizit pentru T1/T7,
|
||
nu o notă de subsol. Bridge-ul are `tests/fake_claude.py` ca referință de tipar.
|
||
|
||
## Pass 7: Community & Ecosystem — N/A
|
||
|
||
Repo privat, un utilizator, un agent. Nu se aplică; nescoruit ca să nu falsifice media.
|
||
|
||
## Pass 8: DX Measurement & Feedback Loops — 4/10
|
||
|
||
Nicio metrică propusă. `_invoke_log` numără tururile; nimic nu numără steering-urile.
|
||
**Finding D8 (LOW):** adaugă `steered=N` la linia de log a turului (deja în T12) — atunci poți
|
||
răspunde peste o lună la „chiar folosesc funcția asta?" fără să ghicești.
|
||
|
||
## DX Scorecard
|
||
|
||
```
|
||
Pass Scor Ce lipsește pentru 10
|
||
--------------------------------- ----- ----------------------------------------
|
||
1. Getting Started 6/10 eco status + eco doctor (D1)
|
||
2. API/CLI Design 8/10 blocul de config scris (D2), max_live=0 (D3)
|
||
3. Error Messages & Debugging 5/10 log per cale ratată (D4) — ridicat la P1
|
||
4. Documentation & Learning 3/10 regula Ralph (D5) + două-căi în CLAUDE.md (D6)
|
||
5. Upgrade & Migration 9/10 —
|
||
6. Dev Environment & Tooling 6/10 fake claude care citește stdin (D7)
|
||
7. Community & Ecosystem N/A repo privat
|
||
8. DX Measurement 4/10 contor de steering (D8)
|
||
--------------------------------- -----
|
||
OVERALL (7 scorate) 6.1/10
|
||
```
|
||
|
||
**TTHW: ~3 min → țintă < 1 min** (cu `eco status` care confirmă starea fără să provoci un tur lent).
|
||
|
||
## DX Implementation Checklist
|
||
|
||
- [ ] ~~**D5** lista protejată a lui Ralph~~ — retrogradat LOW: Ralph e confirmat oprit
|
||
- [ ] **D4** ridică T12 (log per cale ratată) de la P2 la **P1**
|
||
- [ ] **D1** `eco status`: `steering: on/off · N procese vii`; `eco doctor`: verifică `--input-format stream-json`
|
||
- [ ] **D2** scrie blocul concret de config în plan și în `CLAUDE.md`
|
||
- [ ] **D3** `max_live < 1` → dezactivat **cu log**; `idle_reap_s` cu prag minim 60s
|
||
- [ ] **D6** subsecțiune în `CLAUDE.md`: două căi, care e care, de ce
|
||
- [ ] **D7** `tests/fake_claude.py` care citește stdin — prerechizit pentru T1/T7
|
||
- [ ] **D8** `steered=N` pe linia de log a turului
|
||
|
||
---
|
||
|
||
# /autoplan — Phase 3: Eng Review (gate final, pe planul amendat)
|
||
|
||
Voci: `[subagent-only]`.
|
||
|
||
## Step 0: Scope Challenge (pe cod real, nu pe descriere)
|
||
|
||
Planul atinge 6 fișiere + teste, adaugă 2 clase. Sub pragurile de complexitate.
|
||
Verificat pe cod: `send_message` (`claude_session.py:611`) e singurul punct de intrare care se
|
||
schimbă, iar `_run_claude`/`start_session`/`resume_session` rămân intacte pentru
|
||
`heartbeat.py:416` și `planning_session.py:264`. Separarea e reală, nu declarativă.
|
||
|
||
**Scope-ul e corect calibrat. Nicio reducere recomandată.** Singura adăugire acceptată (E1/`/stop`)
|
||
e ~20 de linii peste infrastructura care oricum se construiește.
|
||
|
||
## Section 1: Architecture
|
||
|
||
Graf, mașină de stare și căile de eroare sunt deja diagramate în Phase 1 § Section 1.
|
||
Nu le repet. Ce adaugă faza de inginerie sunt **cursele**, care nu erau acoperite.
|
||
|
||
### Concurență — analiza care lipsea din plan
|
||
|
||
Modelul de threading propus: thread A (worker `asyncio.to_thread`) rulează `run_turn()` și citește
|
||
stdout blocant; thread B (alt worker) cheamă `steer()` și scrie pe stdin; thread C (reaper) poate
|
||
chema `stop()`. Trei fire pe același obiect.
|
||
|
||
```
|
||
Thread A (tur) Thread B (steer) Thread C (reaper)
|
||
────────────── ──────────────── ─────────────────
|
||
inflight = True
|
||
stdout.readline() ──┐
|
||
│ │ read inflight → True
|
||
│ │ │
|
||
result primit │ │ ← FEREASTRA DE CURSĂ
|
||
inflight = False │ │
|
||
│ │ stdin.write() ──▶ pipe posibil închis
|
||
(reaper eligibil) │ BrokenPipeError
|
||
│ │
|
||
└──────────────────────────┴──▶ T7: refă ca tur normal
|
||
```
|
||
|
||
| # | Cursă | Severitate | Fix |
|
||
|---|---|---|---|
|
||
| **E-C1** | **TOCTOU pe `inflight`.** B citește `inflight=True`, A termină turul și `inflight=False`, apoi B scrie. Scrierea nimerește un proces care nu mai e în tur → linia devine turul următor, dar utilizatorul a primit deja ➡️ fără text. | **HIGH** | `steer()` face **check + write sub același `_stdin_lock`**, iar `run_turn()` setează `inflight=False` **tot sub acel lock**. Atunci fereastra dispare; T7 rămâne plasă pentru moartea reală a procesului, nu pentru cursă. |
|
||
| **E-C2** | **Reaper vs steer.** Reaper-ul sare peste procesele `inflight`, dar un proces viu-și-inactiv poate fi oprit exact în timpul unui `steer()` de pe calea de fallback. | MED | `stop()` ia și el `_stdin_lock` înainte să închidă stdin |
|
||
| **E-C3** | **`_stdin_lock` ținut peste I/O de rețea.** Dacă `drain`/`flush` blochează (pipe plin fiindcă procesul nu citește), lock-ul se ține la nesfârșit și blochează și `stop()`. | MED | Scrierile pe stdin sunt mici (o linie JSON) și pipe-ul are 64 KB buffer — practic nu blochează. Documentează invariantul: **niciodată I/O lung sub `_stdin_lock`**. |
|
||
| **E-C4** | `stderr` citit de un al patrulea thread per proces | LOW | Deja acoperit (T3). Fără interacțiune cu celelalte lock-uri. |
|
||
|
||
### Contractul lock-ului per canal se schimbă — și planul nu o spune
|
||
|
||
`send_message` ține azi lock-ul pe tot turul. Steering-ul trebuie **să NU ia lock-ul** — altfel
|
||
se auto-blochează, pentru că lock-ul e deja ținut de turul în zbor. Planul spune corect „fără lock".
|
||
Dar consecința nu e menționată: **contractul pe care `test_claude_session_mutex.py` îl pinează
|
||
explicit devine condiționat de flag.**
|
||
|
||
Docstring-ul acelui test spune: *„This test pins that behavior so future refactors must preserve it."*
|
||
Cu steering pornit, comportamentul „al doilea apel așteaptă" **nu se mai păstrează** — al doilea apel
|
||
face steering și se întoarce imediat. Asta e schimbarea intenționată, dar contractul trebuie rescris,
|
||
nu încălcat tăcut.
|
||
|
||
**E-C5 (HIGH) — test verde fals.** Testul patch-uiește `claude_session._run_claude`. Dacă
|
||
`send_message` rutează spre runner când steering-ul e pornit, `_run_claude` nu mai e chemat deloc,
|
||
`concurrent_seen` nu se setează niciodată și testul **trece degeaba** — verde fără să testeze nimic.
|
||
**Fix:** testul de mutex forțează explicit `steering.enabled=False` (nu se bazează pe default),
|
||
plus un test nou care pinează contractul cu steering pornit: al doilea apel întoarce `__STEERED__`
|
||
fără să deschidă un tur.
|
||
|
||
## Section 2: Code Quality
|
||
|
||
* **Dispecerul:** extrage `_try_steer(channel_id, text) -> bool` din `send_message` (deja notat
|
||
în Phase 1 § Section 5). `send_message` rămâne sub 5 ramuri.
|
||
* **Import lazy:** `claude_session` importă `claude_runner` **în corpul funcției**, nu la nivel de
|
||
modul, fiindcă runner-ul importă parserul din session. Altfel ciclu la import. Scrie-o ca și
|
||
comentariu, nu ca folclor.
|
||
* **DRY:** o singură violare (parserul), deja task T5.
|
||
* Fără abstracții premature: nu introduce o interfață `Backend` cu două implementări pentru două
|
||
căi. `if steering: ... else: ...` explicit e mai lizibil (P5).
|
||
|
||
## Section 3: Test Review
|
||
|
||
Diagrama completă e în Phase 1 § Section 6. Faza de inginerie adaugă:
|
||
|
||
| # | Test lipsă | De ce |
|
||
|---|---|---|
|
||
| **E-T1** | Mutex-ul cu `steering.enabled=False` **forțat explicit** | E-C5 — altfel verde fals |
|
||
| **E-T2** | Contract nou: cu steering pornit, al 2-lea apel întoarce `__STEERED__` fără tur nou | Contractul care înlocuiește pinul vechi |
|
||
| **E-T3** | Cursă: 20 de iterații care fac steer exact la granița `result` | E-C1 — o cursă netestată e o cursă care se întoarce |
|
||
| **E-T4** | `sessions/active.json` primește sid-ul **actualizat după fiecare tur**, nu doar la start | La respawn cu `--resume`, `system/init` poate întoarce un sid nou; dacă nu-l persiști, următorul respawn reia o sesiune moartă |
|
||
| **E-T5** | Timeout distruge procesul → turul următor se reia curat cu `--resume` | Timeout-ul acum aruncă un proces cald; verifică că nu pierde sesiunea |
|
||
|
||
**E-T4 e un bug latent, nu doar un test lipsă.** Planul spune „persistă în `sessions/active.json`",
|
||
dar nu spune **când**. Bridge-ul actualizează `sid` în `finally`-ul fiecărui tur (`bot.py:974`).
|
||
Dacă echo-core îl scrie doar la primul tur, un respawn ulterior folosește un sid vechi.
|
||
→ **Task nou E1 (P1).**
|
||
|
||
## Section 4: Performance
|
||
|
||
Măsurătoarea e în Phase 1 § Section 7 (292-541 MB RSS, `max_live=2`).
|
||
Adaug o observație care lipsea: **timeout-ul devine mai scump.** Azi un timeout omoară un proces
|
||
care oricum murea. Cu proces persistent, un timeout aruncă un proces cald, iar turul următor plătește
|
||
respawn (~2-4 s) **plus** reîncărcarea integrală a istoricului prin `--resume`. Pe o sesiune lungă
|
||
asta nu e neglijabil. Nu schimbă designul — dar `TURN_TIMEOUT` nu trebuie coborât „ca să fie sigur".
|
||
|
||
`_safe_env()` se evaluează la spawn: o rotire de credențiale în keyring **nu ajunge** la un proces
|
||
viu până la respawn. Minor, dar notabil pentru `/otp` — dacă tokenul roa2web se reînnoiește,
|
||
procesul viu are env-ul vechi. Verifică dacă `roa2web_client` citește keyring-ul la runtime
|
||
(atunci e irelevant) sau env-ul de la pornire (atunci e un bug).
|
||
|
||
## Section 5: Security
|
||
|
||
Acoperit în Phase 1 § Section 3. Singurul finding real rămâne **S1** (wrapping `[EXTERNAL CONTENT]`
|
||
la steer, task T1) — o regresie de securitate, nu un risc teoretic. Confirmat pe cod:
|
||
`start_session:489` și `resume_session:563` împachetează; `steer()` trebuie să facă la fel.
|
||
Fără suprafață de rețea nouă. `--dangerously-skip-permissions` e neschimbat față de azi, doar
|
||
fereastra de viață a procesului crește — mărginită de `idle_reap_s`.
|
||
|
||
## Section 6: Hidden Complexity
|
||
|
||
Ce arată simplu în plan și nu este:
|
||
|
||
1. **„`run_turn()` întoarce același dict"** — pare o formalitate. De fapt e contractul care ține
|
||
`start_session`/`resume_session`/`_invoke_log`/`sessions/active.json` nemodificate. Orice câmp
|
||
lipsă se manifestă ca `KeyError` la runtime, într-un thread, la un tur oarecare. Merită un
|
||
test de formă (assert pe setul de chei), nu doar teste de comportament.
|
||
2. **„Reacție ➡️"** — pare cosmetic, dar înseamnă că adaptorul trebuie să distingă
|
||
`__STEERED__` de un răspuns real **în trei locuri diferite**, fiecare cu API propriu de reacții.
|
||
Trei fișiere, trei tipare, trei moduri de a greși.
|
||
3. **„Flag implicit off"** — nu e gratuit: fiecare test nou trebuie să-l seteze explicit în
|
||
ambele sensuri, altfel testezi calea greșită fără să afli (E-C5).
|
||
4. **Reaper-ul** — 30 de linii care, greșite, scurg 500 MB pe oră tăcut.
|
||
|
||
---
|
||
|
||
# /autoplan — Voci independente și corecții
|
||
|
||
Codex indisponibil (binar neinstalat) → toate fazele `[subagent-only]`.
|
||
|
||
## CEO DUAL VOICES — CONSENSUS TABLE
|
||
|
||
```
|
||
═══════════════════════════════════════════════════════════════
|
||
Dimensiune Claude Codex Consensus
|
||
───────────────────────────────────── ─────── ────── ─────────
|
||
1. Premise valide? NO N/A flagged
|
||
2. Problema potrivită? PARTIAL N/A flagged
|
||
3. Scope calibrat corect? NO N/A flagged
|
||
4. Alternative explorate suficient? NO N/A flagged
|
||
5. Riscuri competitive acoperite? PARTIAL N/A flagged
|
||
6. Traiectorie la 6 luni solidă? PARTIAL N/A flagged
|
||
═══════════════════════════════════════════════════════════════
|
||
Codex lipsă ⇒ nicio dimensiune nu poate fi CONFIRMED.
|
||
Findings critice dintr-o singură voce se ridică oricum.
|
||
```
|
||
|
||
## DX DUAL VOICES — CONSENSUS TABLE
|
||
|
||
```
|
||
═══════════════════════════════════════════════════════════════
|
||
Dimensiune Claude Codex Consensus
|
||
───────────────────────────────────── ─────── ────── ─────────
|
||
1. Getting started < 5 min? NO N/A flagged
|
||
2. Denumiri API/CLI ghicibile? PARTIAL N/A flagged
|
||
3. Mesaje de eroare acționabile? PARTIAL N/A flagged
|
||
4. Docs găsibile și complete? NO N/A flagged
|
||
5. Cale de upgrade sigură? YES N/A —
|
||
6. Mediu de dezvoltare fără fricțiune? YES N/A —
|
||
═══════════════════════════════════════════════════════════════
|
||
Scoruri DX voce independentă: getting started 5, API 6, erori 6, docs 4,
|
||
upgrade 7, mediu 8, măsurare 3. TTHW: ~15 min ca scris → ~3 min cu fix-urile.
|
||
```
|
||
|
||
## Corecții factuale la planul original (verificate pe cod)
|
||
|
||
### ❌ Riscul #2 era INVERSAT — se elimină
|
||
|
||
Planul original spunea: *„`--system-prompt` se fixează la spawn. Azi se reconstruiește din
|
||
`personality/*.md` la fiecare mesaj."*
|
||
|
||
**Fals, verificat:** `build_system_prompt()` e chemat în exact două locuri —
|
||
`claude_session.py:486` (`start_session`) și `scheduler.py:402`. **`resume_session` nu pasează
|
||
deloc `--system-prompt`** (liniile 565-571). Deci editările din `personality/` **deja** nu se aplică
|
||
mid-sesiune; se aplică doar la o sesiune nouă, adică după `/clear`.
|
||
|
||
Fix-ul propus (hash pe fișiere → respawn) ar fi fost **comportament nou**, care ar omorî procesul
|
||
viu și steering-urile în așteptare de fiecare dată când Marius editează `SOUL.md` cu Echo pornit.
|
||
**Decizie: riscul se șterge, fix-ul nu se implementează.** Reîncărcarea personalității rămâne legată
|
||
de `/clear`, exact ca azi.
|
||
|
||
### ❌ Citarea implementării de referință era ambiguă
|
||
|
||
`~/workspace/romfastsql/proxmox/lxc171-claude-agent/` **local** conține doar README + scripturi —
|
||
clona locală e stale. Codul citat (`discord-bridge/runner.py`, `bot.py:652-663`) există pe
|
||
**LXC 171**, accesat prin `ssh echo@10.0.20.201 "sudo pct exec 171 -- cat …"`. Citarea trebuie să
|
||
numească gazda, altfel implementatorul caută local și nu găsește nimic.
|
||
|
||
### ❌ „Flagul poate fi citit la cald" era fals
|
||
|
||
`claude_session.py:142` face `ALLOWED_TOOLS = _load_allowed_tools()` **la nivel de modul**, citind
|
||
`config.json` la import, și modulul **nu importă deloc `src.config`**. Dacă steering copiază tiparul,
|
||
flagul e citit o singură dată la pornire, iar rollback-ul cere restart.
|
||
**Fix:** citește `steering.*` prin `Config()` în `router.py` (care deține deja `_get_config()`),
|
||
**per apel**, nu la import.
|
||
|
||
### ❌ Etapa 6 cerea o cheie inexistentă
|
||
|
||
`channels.echo-core` are doar `['id','default_model']`. „Flip pe UN canal" n-are pe ce să se sprijine.
|
||
**Fix:** adaugă suport pentru `channels.<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.
|
||
|
||
---
|
||
|
||
<!-- AUTONOMOUS DECISION LOG -->
|
||
## 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
|
||
|
||
```json
|
||
"steering": { "enabled": false, "idle_minutes": 20, "max_live": 2 }
|
||
```
|
||
Citit prin `Config()` **per apel** în `router.py`, nu la import. `max_live <= 0` → oprit cu log.
|
||
`idle_minutes < 1` → clamp la 1. Kill switch: `ECHO_STEERING=off`.
|
||
|
||
### Fișiere atinse (7)
|
||
|
||
`src/claude_runner.py` (nou) · `src/claude_session.py` · `src/router.py` ·
|
||
`src/adapters/discord_bot.py` · `src/adapters/telegram_bot.py` · `src/adapters/whatsapp.py` ·
|
||
**`src/voice/pipeline.py`** · plus `config.json`, `CLAUDE.md`, `tests/`
|
||
|
||
## Status
|
||
|
||
**EXECUTAT 2026-09-02.** Toate etapele (0-6) implementate pe master, necommitate.
|
||
Steering livrat cu `steering.enabled: false` în `config.json` și zero override-uri
|
||
per canal — `/stop` e activ, steering-ul rămâne stins până la flip manual.
|
||
|
||
Suită: 1199 passed / 12 failed — toate cele 12 confirmate pre-existente pe HEAD curat
|
||
(worktree separat): 2 `test_claude_session` (assert stale pe system prompt), 1 `test_cli`
|
||
(`assets/voice/*.wav` lipsă), 4 `test_discord` (`owned_bot.user` None în fixture),
|
||
3 `test_heartbeat`, 1 `test_dashboard_ralph_endpoint`, 1 `test_dashboard_unified_index`.
|
||
`tests/test_dashboard_projects_endpoint.py` se blochează — și pe HEAD curat.
|
||
|
||
Abateri față de plan, verificate pe cod:
|
||
- **M1 nereproductibil** — `RunnerRegistry.get()` întoarce procesul existent al canalului
|
||
înaintea oricărei verificări de capacitate, deci „doi scriitori pe același session_id"
|
||
nu e accesibil dinspre registry. Garda ar fi fost cod mort; dispecerul evită cazul și
|
||
pe calea C2 (așteaptă lock-ul, dar rulează prin ACELAȘI proces viu).
|
||
- **session_id se ia din două locuri**, nu doar din `system`/`init`: init acoperă turul
|
||
care expiră înainte de `result`, iar `result` reîmprospătează la fiecare tur reușit.
|
||
- **`eco status` numără prin `pgrep -f "--input-format stream-json"`**, nu prin registry:
|
||
`eco` e proces separat, deci `live_count()` ar fi fost structural mereu 0.
|
||
|
||
## Addendum — două findings din notificările finale ale vocilor
|
||
|
||
### A1 — Sentinela trebuie tratată într-un singur loc. **HIGH**
|
||
|
||
Verificat: `__AUDIO__:` e tratat în **Discord (3 locuri)** și **Telegram (1)**, dar
|
||
**deloc în WhatsApp**. Convenția existentă are deja o gaură — un utilizator WhatsApp care
|
||
declanșează TTS primește șirul literal `__AUDIO__:/cale`. **Bug preexistent, nu introdus aici.**
|
||
|
||
`__STEERED__` ar replica exact tiparul, acum pe **4 căi** (Discord, Telegram, WhatsApp, voice).
|
||
**Fix:** o singură funcție `is_sentinel(response)` pe calea partajată, nu patru verificări
|
||
copiate. Repară și bug-ul preexistent de pe WhatsApp ca efect secundar.
|
||
|
||
### A2 — `_safe_env()` la spawn schimbă comportamentul comutatorului OpenRouter. **HIGH**
|
||
|
||
`_safe_env()` (`claude_session.py:231-249`) verifică semaforul `.use_openrouter` și, dacă există,
|
||
încarcă `~/.claude-env.sh` — care setează variabilele `ANTHROPIC_*` ca să rutează prin OpenRouter.
|
||
|
||
Azi asta se re-evaluează **la fiecare tur**, deci crearea sau ștergerea semaforului are efect
|
||
de la mesajul următor. Cu proces persistent se evaluează **o singură dată, la spawn**: comuți
|
||
providerul și **nu se întâmplă nimic** până la respawn — sau, mai rău, rămâi pe OpenRouter după ce
|
||
ai șters semaforul, crezând că nu ești.
|
||
|
||
Nu e „env stale, minor". E un comutator de provider care încetează tăcut să funcționeze.
|
||
**Fix:** la începutul fiecărui tur, compară starea semaforului cu cea de la spawn; dacă diferă,
|
||
oprește procesul și respawn-ează. Ăsta e singurul respawn-on-change justificat — spre deosebire
|
||
de cel pe `personality/` (Riscul #2, șters), aici starea chiar se aplică per proces.
|
||
|
||
### A3 — Ajustări de etapizare din vocea DX
|
||
|
||
* `eco status` cu `steering: on/off · N procese vii` se mută din **Etapa 5 în Etapa 2** —
|
||
Etapele 2-4 sunt exact unde depanezi.
|
||
* T15 primește o **rețetă de reproducere**: „ca să testezi manual, cere ceva cu un `sleep` de 30s+
|
||
în Bash, apoi trimite al doilea mesaj". Fără ea, fiecare mentenator viitor repetă spike-ul eșuat.
|