Skip to content

fix(core): laco de traducao sobrevive a falha de UMA frase — erro recuperavel vs. fatal (#45) - #60

Merged
caioross merged 3 commits into
mainfrom
auto/issue-45-loop-resiliente
Aug 1, 2026
Merged

caioross merged 3 commits into
mainfrom
auto/issue-45-loop-resiliente

Conversation

@caioross

@caioross caioross commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Contexto

O laço de tradução rodava inteiro dentro do mesmo try que cobre o setup: uma exceção em qualquer engine (Piper engasgando num texto vindo do Argos, hiccup de CUDA) encerrava a direção pelo resto da sessão.

Pior que parar: parava sem setar _stop. _capture/_segmenter seguiam rodando, o medidor de entrada continuava se mexendo, seg_q enchia e passava a emitir overload — o app parecia vivo, segurando o device de captura e queimando CPU, sem traduzir nada. A UI recebia error, desabilitava o Parar e a única saída era Iniciar: recarga de modelos no meio da conversa.

Era a única etapa do pipeline sem resiliência. A captura já tem laço de reconexão com orçamento de retries (#12) e _play já isola device a device (#38).

O que mudou

laguna_core.py

  • _process_segment(seg, stt, mt, tts) — corpo do laço extraído sem mudança de lógica (o diff é literalmente o mesmo bloco, com continue → return). Isso torna a falha de uma frase isolável num try próprio e o caminho quente testável com dublês.
  • _translation_loop(seg_q, stt, mt, tts) — falha de segmento emite error.segment_failed (com recoverable=True) e segue para o próximo. Contador de falhas consecutivas; uma frase boa zera (mesma doutrina de CAPTURE_HEALTHY_S).
  • SEGMENT_MAX_FAILURES = 3 — no limiar emite error.direction_lost e encerra de verdade, setando _stop ANTES de sair de _run, para captura e segmentador saírem junto (o anti-zumbi da issue).
  • Exceção com _stop já setado não vira erro: parar no meio de uma frase é Stop, não falha — mesmo guarda de _OutputSink._report.
  • Falha de setup continua fatal na hora: "sem modelo" não virou retry infinito.
  • _emit_key passa a aceitar **data para campos que a UI lê sem traduzir.

static/

  • app.js: error com recoverable mostra o aviso e não derruba os botões (derrubar era o bug — Parar desabilitado com o worker vivo). Erro fatal segue igual.
  • i18n.js: error.segment_failed e error.direction_lost em PT e EN.

tests_unit/test_translation_loop.py (novo, 6 casos, sem áudio/modelo/GPU/rede): falha isolada → próxima frase processa; a guarda cobre MT e TTS além do STT; 3 falhas alternadas nunca viram fatal; N consecutivas → fatal + para de consumir a fila + _stop setado; exceção durante o Stop não vira erro; segmento sem fala não gasta orçamento de falhas.

A métrica não passa a mentir por omissão (família da #21): as latências só entram em _lat_* no fim de _process_segment, então o segmento que falhou não entra na conta — e o evento de erro sempre aparece. A diferença é o laço sobreviver a ele.

Gate (HANDBOOK §6) — resultado real

  • T1 — compileall -q . → COMPILE_OK; import-smoke de fase0_poc, laguna_core, laguna_server → IMPORTS_OK (C:\Python313\python.exe).
  • Unitários — pytest tests_unit -q → 54 passed (48 antes + 6 novos), inclusive test_i18n_parity.py.
  • T2 (tocou laguna_core.py) — test_offline.py "…/dry_pt2en.wav" --direction pt2en --model small --device auto → verde, out_gate.wav gerado:
    STT(pt): 'Ei, galera, eu estou testando o translator em real tempo para o Discord.'  [646ms]
    MT(en):  "Hey, guys, I'm testing the translator in real time for Discord."           [425ms]
    TTS: 4.46s                                                                          [2536ms]
    
    Números de cold start numa worktree nova (Whisper/Piper baixados na hora, primeira inferência) — servem como prova de que o pipeline roda ponta a ponta, não como benchmark.
  • T3 (tocou static/) — node --check app.js + node --check i18n.js → JS_OK; paridade PT/EN pelo teste versionado.

Riscos

  • Sem impacto de latência: nenhuma constante de VAD, default de modelo ou alocação nova no caminho quente. O contador só existe no caminho de exceção — o caminho feliz executa exatamente o mesmo código de antes.
  • Promessa 100% local intacta: zero chamada de rede em runtime, zero dependência nova.
  • Contrato WS: error ganha um campo opcional recoverable; cliente antigo ignora e cai no comportamento de hoje.
  • Trade-off do limiar: 3 falhas consecutivas ≈ o engine morreu de verdade. Baixo demais mataria direção por azar; alto demais deixa o usuário sem tradução por mais tempo. Alinhado com CAPTURE_MAX_RETRIES = 5, mas menor porque aqui cada tentativa custa uma frase da conversa.

Fora de escopo (como a issue pediu)

Não ampliei a PR para: (a) laguna_server remover o worker de _workers no erro fatal — hoje o worker fatal fica registrado com _stop setado, e o próximo Iniciar o recria; (b) tratamento visual dedicado do erro recuperável (hoje ele reusa o status error do painel, com os botões preservados). Ambos merecem issue própria.

Solicito quórum (HANDBOOK §7)

Closes #45

caioross and others added 2 commits August 1, 2026 12:44
O laco de traducao rodava inteiro dentro do mesmo `try` que cobre o setup:
uma excecao em qualquer engine (Piper engasgando num texto do Argos, hiccup
de CUDA) encerrava a direcao pelo resto da sessao. Pior, sem setar `_stop`:
`_capture`/`_segmenter` seguiam rodando, o medidor de entrada continuava se
mexendo, `seg_q` enchia e emitia `overload` — o app parecia vivo, segurando
o device de captura, e nao traduzia mais nada. A UI recebia `error` e
desabilitava o Parar, entao a unica saida era Iniciar (recarga de modelos no
meio da conversa). Era a unica etapa do pipeline sem resiliencia: a captura
ja tem laco de reconexao com budget de retries (#12) e `_play` ja isola
device a device (#38).

- `_process_segment(seg, stt, mt, tts)`: corpo do laco extraido sem mudanca de
  logica — a falha de uma frase fica isolavel num `try` proprio e o caminho
  quente vira testavel com dubles, sem sounddevice/modelo/GPU.
- `_translation_loop(seg_q, stt, mt, tts)`: falha de segmento emite
  `error.segment_failed` (`recoverable=True`) e SEGUE para o proximo. Contador
  de falhas CONSECUTIVAS; frase boa zera (mesma doutrina de CAPTURE_HEALTHY_S).
- `SEGMENT_MAX_FAILURES = 3`: no limiar, emite `error.direction_lost` e encerra
  de verdade — setando `_stop` ANTES de sair, para nao deixar thread zumbi.
  Numero de robustez, nao de latencia: o caminho feliz nunca toca o contador.
- Excecao com `_stop` ja setado nao vira erro: parar no meio de uma frase e
  Stop, nao falha (mesmo guarda de `_OutputSink._report`).
- Falha de SETUP continua matando a direcao na hora — "sem modelo" nao vira
  retry infinito.
- `_emit_key` aceita `**data` para campos que a UI le sem traduzir.
- `static/app.js`: `error` com `recoverable` mostra o aviso e NAO derruba os
  botoes (derrubar era o bug: Parar desabilitado com o worker vivo).
- `static/i18n.js`: `error.segment_failed` e `error.direction_lost` em PT/EN.

Metrica intacta: as latencias so entram em `_lat_*` no fim de
`_process_segment`, entao segmento que falhou nao entra na conta — e o erro
sempre aparece, o laco so passa a sobreviver a ele.

Refs #45

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 casos sobre `_translation_loop` com dubles de STT/MT/TTS (o `conftest.py` ja
stuba sounddevice; `_play` e neutralizado porque a reproducao tem cobertura
propria em test_output_sink.py):

- falha em 1 segmento nao impede o proximo (o evento vem com `recoverable`);
- a guarda cobre MT e TTS, nao so o STT;
- frase boa zera o contador: 3 falhas alternadas nunca viram fatal;
- N consecutivas emitem `error.direction_lost`, param de consumir a fila e
  setam `_stop` (o anti-zumbi da issue);
- excecao com `_stop` ja setado nao vira erro (Stop nao e falha);
- segmento sem fala nao gasta orcamento de falhas.

A fila de teste sinaliza o stop no `get` vazio — mesmo ponto onde o laco real
espera — para o teste encerrar sem timer nem sleep e sem tocar o caminho de
excecao sob teste.

Refs #45

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

O erro recuperavel introduzido nesta PR pintava o painel de vermelho e
ficava la: nenhum evento do caminho feliz (stt/mt/latency) repinta status,
entao uma frase perdida no minuto 2 deixava "Frase perdida (2/3)" na tela
pelo resto da sessao — com o contador ja zerado no backend na frase boa
seguinte. Era o erro simetrico ao que a issue corrigiu: antes o painel
mentia dizendo que morreu; agora mentiria dizendo que esta quase morrendo.

- aviso vai para a classe `warn` (ambar, transitorio) e volta a
  `status.running` depois de RECOVERABLE_STATUS_MS;
- toda pintura de status cancela a volta pendente, para o timer nunca
  repintar "rodando" por cima de um erro fatal, de um Parar ou de um novo
  Iniciar que cheguem dentro da janela;
- `.status.warn` no style.css reusa a cor de aviso ja existente, em vez de
  emprestar a classe `loading` (que significa outra coisa).

Achado do quorum adversarial (3/3 lentes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@caioross

caioross commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

🩺 Parecer do PR Doctor — quórum §7.2 concluído, 3× APROVA

Balde B (área de quórum): diff em laguna_core.py + contrato WS (campo novo no evento error), e o corpo pediu quórum explicitamente. Diff lido inteiro, CI verde, MERGEABLE/CLEAN.

Rodada 1 — 1 APROVA / 2 VETA

As três lentes convergiram num achado que esta PR introduziu: o erro recuperável pintava o painel de vermelho e nunca voltava. Nenhum evento do caminho feliz (stt/mt/latency) repinta status, e status.listening só é reemitido no retry de captura — então uma frase perdida no minuto 2 deixava Frase perdida (2/3) na tela pelo resto da sessão, com o contador já zerado no backend na frase boa seguinte. O removeAttribute('data-i18n') de setStatus ainda fossilizava o texto no idioma em que chegou. Era o erro simétrico ao da issue: antes o painel mentia dizendo que morreu; assim mentiria dizendo que está quase morrendo — e a reação previsível do usuário (Parar/Iniciar no meio da call, recarregando modelos) é exatamente o que a #45 existe para evitar.

Reparo aplicado — 48b85e2 (só frontend)

  • aviso recuperável vai para a classe warn (âmbar, transitório) e volta sozinho a status.running após RECOVERABLE_STATUS_MS; como a volta é via setStatusKey, o data-i18n é reposto e o estado final retraduz no toggle PT/EN;
  • toda pintura de status cancela a volta pendente (clearRecoverTimer no topo de setStatus/setStatusKey), então o timer não consegue repintar "rodando" por cima de um erro fatal, de um Parar ou de um novo Iniciar que cheguem dentro da janela;
  • .status.warn no style.css em vez de emprestar a classe loading, que significa outra coisa.

laguna_core.py ficou byte a byte idêntico ao SHA anterior (git diff 0833b0c..HEAD --stat → só static/), então o T2 do autor segue valendo.

Rodada 2 — veredito das 3 lentes sobre 48b85e2

  • Pipeline/Latência — APROVA. Extração de _process_segment é semanticamente idêntica ao bloco antigo (os três continue viraram return nos mesmos pontos; _lat_*/_push_latency não se moveram). Engines são stateless por chamada (condition_on_previous_text=False), então frase que falha não envenena a próxima. Custo no caminho quente: um frame de função e um try/except zero-cost — ~0,05 µs/segmento contra um orçamento de 450 ms. _stop.set() antes do return no fatal faz _capture, _segmenter e _run_fullduplex saírem junto; nenhum lock é segurado, sem deadlock.
  • Privacidade/Robustez — APROVA. Zero rede, telemetria ou dependência nova. except Exception não captura KeyboardInterrupt/SystemExit (são BaseException); erro determinístico falha 3× e vira fatal com detail, não retry infinito. _play não propaga erro de device, então fone morto não consome o orçamento de falhas. No reparo, setStatus usa textContent (sem innerHTML novo) e a classe é literal — sem XSS.
  • Produto/UX — APROVA. recoverable chega mesmo no browser: _emit_key(**data) → on_event = _broadcast cru → json.dumps do dict inteiro, sem allowlist. Campo aditivo e opcional — cliente antigo cai no comportamento de hoje. Paridade i18n 107/107 chaves, mesmos placeholders nos dois idiomas. Fatal (error.direction_lost) segue derrubando os botões corretamente, e um Iniciar depois dele funciona.

Achados reais que não bloqueiam este merge

Verifiquei cada um no código e classifiquei como dívida pré-existente — a PR não os introduz nem agrava, e corrigi-los aqui ampliaria o diff além da #45 (HANDBOOK §8). Viram issue própria:

  1. error.capture_lost (laguna_core.py:695-702) dá return sem self._stop.set() — é o zumbi simétrico ao que esta PR fecha. Idêntico em origin/main.
  2. error.play não leva recoverable=True, embora o sink se auto-recupere (_close_stream() força reabertura na próxima frase) — o comentário de _OutputSink._report já reconhece o sintoma. Agora que o mecanismo existe, é candidato natural.
  3. _workers não solta a direção que morre sozinha, então um F5 depois do fatal mostra "rodando" com Parar habilitado. Já valia para qualquer morte espontânea; esta PR torna a morte espontânea mais rara.

Merge autorizado por quórum. Excelente trabalho na PR — em especial os testes serem herméticos de verdade (sem áudio, modelo, GPU ou rede) e a _FilaQueEsvazia não mascarar o caminho fatal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pipeline: uma exceção em UM segmento mata a direção inteira — o laço de tradução não tem resiliência (e deixa threads zumbis)

1 participant