Skip to content

fix(core): soluco de UM device de saida nao para mais a direcao — retry recuperavel antes do terminal (#54) - #64

Merged
caioross merged 2 commits into
mainfrom
auto/issue-54-output-retry
Aug 5, 2026
Merged

fix(core): soluco de UM device de saida nao para mais a direcao — retry recuperavel antes do terminal (#54)#64
caioross merged 2 commits into
mainfrom
auto/issue-54-output-retry

Conversation

@caioross

@caioross caioross commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Contexto

O _OutputSink sempre foi resiliente por dentro: uma exceção no write() fecha só aquele stream e a frase seguinte reabre; os outros sinks nem sentem. O que era terminal é o relato: qualquer falha virava error.play, e static/app.js trata erro não-recuperável como fim de sessão — setStatus('error') + state.running.delete(dir) + toggleButtons(panel, false), e stop.disabled = !running.

Resultado no uso real: um soluço em UM device (fone entrando em power save, glitch de driver) deixava o Stop desabilitado com o DirectionWorker vivo — ainda capturando, traduzindo e mandando áudio pro Discord. A UI dizia "erro"; o backend seguia rodando. Única saída: recarregar a página.

O que mudou

laguna_core.py — o degrau que faltava. _OutputSink passa a contar falhas consecutivas e o callback vira on_error(device, exc, consecutivas). O contador vive no sink porque é ele quem sabe se o write() deu certo — e um contador por sink já é um contador por device, então a falha de um nunca contamina o outro (AC 5 sai por construção). Quem classifica é o worker:

  • abaixo de OUT_SINK_MAX_ERRORSerror.output_retry com recoverable=True: aviso âmbar transitório, painel volta sozinho para "rodando", Stop segue habilitado;
  • no teto → o terminal error.play, agora identificando o device e quantas frases seguidas falharam;
  • uma frase tocada com sucesso zera o orçamento (else do try no laço do sink), para que falhas espaçadas ao longo de uma call (um soluço por hora) nunca somem até o teto.

OUT_SINK_MAX_ERRORS = 3, e não os 5 da captura: aqui cada falha já custou uma frase muda naquele device — o usuário precisa saber cedo. É número de robustez, não de latência; o caminho feliz nunca toca este contador.

Divergência consciente do AC 1 (o item a olhar no quórum): a issue pede kind:"status" + status.output_retry. Usei kind:"error" + recoverable=True — o degrau que a #45 criou para exatamente este caso — por dois motivos: (a) case 'status' em app.js pinta o painel de verde "rodando" com um texto de falha e não volta sozinho, enquanto recoverable já dá âmbar transitório com auto-retorno; (b) o caminho recuperável já existe na UI, então nenhuma linha de static/app.js muda para isto funcionar. O que o AC 1 exige de fato — evento não-terminal, direção segue marcada como rodando, Stop habilitado, args dev/attempt/max/detail — está atendido.

static/i18n.js — chave nova error.output_retry em PT e EN; error.play reescrita (era play(dev={dev}): {detail}, cru demais para um evento terminal) para dizer que a saída foi perdida após N frases. Nenhuma chave órfã, nenhuma removida.

AC 4 intacto: o guarda de shutdown do _report() (#52) vem antes do incremento — o write() abortado pelo close() não emite nem envenena o contador de um sink que nem vai sobreviver ao Stop.

Gate (HANDBOOK §6) — resultado real

  • T1 — verde. python -m compileall -q .COMPILE_OK; import fase0_poc, laguna_core, laguna_serverIMPORTS_OK (C:\Python313\python.exe).
  • T2 — verde (toca laguna_core.py). test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto: rodou em cuda/float16, out_gate.wav gerado, STT 591ms / MT 429ms / TTS 2475ms (cold start da worktree — baixou os modelos). Nenhuma constante de VAD/latência nem default de modelo foi tocada, então não se aplica benchmark antes/depois.
  • T3 — verde (toca static/). node --check static/app.js e static/i18n.jsJS_OK; paridade PT/EN pelo teste versionado tests_unit/test_i18n_parity.py → passou.
  • Testes unitários — verdes: pytest tests_unit/ -q58 passed (54 antes + 4 novos).

Novos em tests_unit/test_output_sink.py: falha isolada é recoverable e não para a direção; OUT_SINK_MAX_ERRORS consecutivas viram terminal identificando o device; frase boa no meio zera o orçamento ([1, 2, 1], não [1, 2, 3]); orçamento é por device e não contamina o vizinho. Os 8 testes existentes foram atualizados para a nova aridade do callback (incluindo o de shutdown silencioso, que continua exigindo errors == []). Tudo com stream falso — sem PortAudio, device, modelo ou GPU: roda no job tests-unit da CI.

Riscos

  • Mudança de assinatura de on_error (_OutputSink é privado; grep no repo confirma que os únicos consumidores são laguna_core.py e tests_unit/test_output_sink.py, ambos atualizados neste PR).
  • Device inválido desde o start agora leva 3 falhas para virar terminal em vez de 1. Na prática o /api/start já barra device de saída inválido antes de subir o worker (error.start_output_device_invalid), então o caso realista aqui é sempre o de falha em runtime.
  • dev continua sendo o índice do PortAudio, não um rótulo legível — mantido como estava para não chamar query_devices() dentro de um caminho de erro. Melhoria de UX possível em issue própria.
  • Contrato REST/WS: nenhum evento removido; error.play mantém dev/detail e ganha max. Cliente antigo que ignore recoverable volta ao comportamento de hoje (trata como terminal) — degradação segura.

Solicito quórum (HANDBOOK §7)

Closes #54

Toda falha de `_OutputSink` virava `error.play`, e `static/app.js` trata erro
nao-recuperavel como fim de sessao: status 'error', `running.delete(dir)` e
`toggleButtons(panel, false)` — ou seja, Stop DESABILITADO com o worker ainda
capturando, traduzindo e mandando audio pro Discord. Um fone entrando em power
save deixava a UI e o backend dessincronizados, e a unica saida era recarregar
a pagina.

O sink ja se recuperava por dentro (o stream cai, a frase seguinte reabre): o
que faltava era o degrau no relato. Agora `_OutputSink` conta falhas
CONSECUTIVAS por device (o contador vive no sink, que e quem sabe se o
`write()` deu certo, e um contador por sink ja e um por device) e o worker
decide: abaixo de `OUT_SINK_MAX_ERRORS` emite `error.output_retry` com
`recoverable=True` — aviso ambar transitorio, direcao segue rodando, Stop
segue habilitado — e no teto emite o terminal `error.play`, agora identificando
o device e o numero de falhas. Uma frase tocada com sucesso zera o orcamento,
para que falhas espacadas nunca somem ate o teto.

Mesma doutrina ja aplicada a captura (CAPTURE_MAX_RETRIES) e ao laco de
traducao (SEGMENT_MAX_FAILURES, #45). Reusar o `recoverable` da #45 em vez de
um `kind:"status"` novo mantem `static/app.js` intocado — status pintaria o
painel de verde "rodando" com um texto de falha, e nao voltaria sozinho.

O guarda de shutdown do `_report()` (#52) fica intacto: o write abortado pelo
`close()` nao emite nem incrementa o contador.

Closes #54
@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 quorum adversarial (Lente Produto/UX) vetou com vetor concreto: depois do
terminal `error.play` a UI ja derrubou os botoes (`app.js`: setStatus('error')
+ toggleButtons(false) + running.delete), mas o zerar-em-sucesso do orcamento
deixava a falha SEGUINTE do mesmo device voltar como `recoverable` — e o
caminho recuperavel repinta o painel de VERDE "Rodando" 4s depois, com o Parar
desabilitado. Estado que nao existia antes desta issue: na main, `error.play`
nunca era recoverable.

`DirectionWorker._sinks_perdidos` trava o veredito por device: device que ja
custou OUT_SINK_MAX_ERRORS frases seguidas nao volta a ser soluco, e o terminal
tambem para de ser re-emitido a cada frase. `_open_sinks` limpa a trava, para
que uma direcao nova nunca herde o veredito da anterior. Sem lock: so a thread
de cada sink escreve, e cada uma escreve a propria chave.

Testes (3 novos, sem PortAudio/modelo/GPU): terminal nao volta a recuperavel
(verificado que falha sem a trava), vizinho vivo segue reportando normalmente,
e `_open_sinks` zera o veredito.

Refs #54
@caioross

caioross commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Parecer do PR Doctor — quórum concluído, merge autorizado

Classificação: área de quórum (HANDBOOK §7.2 — diff em laguna_core.py, e o corpo pede quórum). Diff lido inteiro; CI verde; mergeable: CLEAN.

1ª convocação (SHA 49fafc1) — 2 APROVA, 1 VETO

Lente Pipeline/Latência — APROVA. Nada muda no áudio (write() segue ascontiguousarray, ganho e _update_level intactos); o único acréscimo ao caminho quente é um int no else do try (laguna_core.py:203-207), sem lock/alocação/I/O; self._erros é tocado só pela thread do sink (:190, :202, :207); nenhuma constante de VAD/latência tocada — o bloco OUT_SINK_*/CAPTURE_* é byte-idêntico à main, então não se aplica benchmark.

Lente Privacidade/Robustez — APROVA. Zero rede/telemetria/subprocess no diff; detail=str(exc) nasce do PortAudio e trafega só pelo WS local. O guarda de shutdown continua correto: _report retorna antes do incremento (:224-227), então o abort() do Stop não envenena o orçamento. Único consumidor de produção de on_error é :951, atualizado.

Lente Produto/UX — VETO, com vetor concreto: emitido o terminal error.play, a UI já derrubou os botões (app.js: setStatus('error') + toggleButtons(false) + running.delete) — mas o zerar-em-sucesso deixava a falha seguinte do mesmo device voltar como recoverable, e o caminho recuperável repinta o painel de VERDE "Rodando" com o Parar desabilitado 4s depois. Estado que não existia antes desta issue: na main, error.play nunca era recoverable.

Reparo (commit 23df748)

Veto acolhido. DirectionWorker._sinks_perdidos trava o veredito por device: emitido o terminal, _on_sink_error retorna cedo para aquele device — o repintar verde vira inalcançável, e o terminal deixa de ser re-emitido a cada frase (risco residual que as três lentes apontaram). _open_sinks limpa a trava antes de construir os sinks, para que uma direção nova não herde veredito. Sem lock: a escrita é por chave, no caminho de exceção.

3 testes novos, hardware-free (monkeypatch de _open_output_stream, sem PortAudio/modelo/GPU). Verifiquei que pegam a regressão: com a trava neutralizada, test_depois_do_terminal_o_device_nao_volta_a_ser_recuperavel falha com assert 4 == 3 — o 4º evento era exatamente o recoverable pós-terminal.

Gate na worktree (C:\Python313\python.exe): T1 COMPILE_OK + IMPORTS_OK; T2 (toca pipeline) test_offline.py dry_pt2en.wav --direction pt2en --device autocuda/float16, out_gate.wav gerado, STT 640ms / MT 566ms / TTS 3187ms; T3 JS_OK + paridade PT/EN pelo teste versionado; pytest tests_unit/ -q61 passed.

2ª convocação (SHA 23df748) — 3 APROVA

  • Produto/UX: "o item 2 do meu veto está fechado, e fechado no lugar certo". Sobre o item 1 (terminal não seta _stop): aceita que não é regressão — a main tem o mesmo buraco disparando na 1ª falha; a PR o adia para a 3ª e elimina o caso comum. Item 3 (dev={dev} cru) não sustenta veto: a main já mostrava play(dev={dev}).
  • Privacidade/Robustez: delta sem rede; a trava nunca engole o primeiro aviso de um device; clear() está depois do guard de _stop e antes da criação dos sinks; _close_sinks corretamente não limpa o set (limpar abriria janela para callback em voo re-emitir o terminal).
  • Pipeline/Latência: custo zero no caminho quente; a trava porteia só o _emit_key — o sink segue vivo, reabre o stream e o áudio volta se o device voltar; os 3 testes falham de fato se a trava, o clear() ou o "por device" sumirem.

Ressalvas registradas (não bloqueiam, viram trabalho futuro)

  1. Terminal não seta self._stop — captura/passthrough seguem vivos com o Parar desabilitado. Pré-existente (na main dispara na 1ª falha); a decisão "perder 1 de N saídas mata a direção?" é de produto e pertence à core/server: aplicar a doutrina da #45 aos caminhos irmãos — captura perdida vira zumbi e falha de saída derruba a UI à toa #62/fix(core/server): captura perdida encerra a direcao e running para de anunciar direcao morta (#62) #63.
  2. hello do WS não carrega o último erro terminal por direção — um F5 depois do terminal repinta VERDE "Rodando". Buraco sistêmico, vale igual para error.direction_lost; o conserto é no hello, não na trava.
  3. output_devices sem dedup em laguna_server.py deixa a corrida check-then-act tecnicamente aberta (custo máximo: um error.play duplicado e idempotente). O comentário em laguna_core.py:343-345 afirma a invariante com mais firmeza do que o servidor garante — imprecisão de comentário, não de código.
  4. dev={dev} é índice cru numa mensagem agora user-facing; sugestão da lente: rótulo cacheado em _open_sinks (padrão de _capture_device_label), fora do caminho de erro.
  5. Device que trava dentro do write() sem exceção segue fora de qualquer orçamento — pré-existente, intocado.

Veredito: 3× APROVA no SHA 23df748 → squash-merge.

@caioross
caioross merged commit 363e20e into main Aug 5, 2026
2 checks passed
@caioross
caioross deleted the auto/issue-54-output-retry branch August 5, 2026 05:30
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.

audio: falha transitória em UM device de saída marca a direção como parada e desabilita o Stop — com o worker ainda rodando

1 participant