fix(core): soluco de UM device de saida nao para mais a direcao — retry recuperavel antes do terminal (#54) - #64
Conversation
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
|
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
Parecer do PR Doctor — quórum concluído, merge autorizadoClassificação: área de quórum (HANDBOOK §7.2 — diff em 1ª convocação (SHA
|
Contexto
O
_OutputSinksempre foi resiliente por dentro: uma exceção nowrite()fecha só aquele stream e a frase seguinte reabre; os outros sinks nem sentem. O que era terminal é o relato: qualquer falha viravaerror.play, estatic/app.jstrata erro não-recuperável como fim de sessão —setStatus('error')+state.running.delete(dir)+toggleButtons(panel, false), estop.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
DirectionWorkervivo — 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._OutputSinkpassa a contar falhas consecutivas e o callback viraon_error(device, exc, consecutivas). O contador vive no sink porque é ele quem sabe se owrite()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:OUT_SINK_MAX_ERRORS→error.output_retrycomrecoverable=True: aviso âmbar transitório, painel volta sozinho para "rodando", Stop segue habilitado;error.play, agora identificando o device e quantas frases seguidas falharam;elsedotryno 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. Useikind:"error"+recoverable=True— o degrau que a #45 criou para exatamente este caso — por dois motivos: (a)case 'status'emapp.jspinta o painel de verde "rodando" com um texto de falha e não volta sozinho, enquantorecoverablejá dá âmbar transitório com auto-retorno; (b) o caminho recuperável já existe na UI, então nenhuma linha destatic/app.jsmuda para isto funcionar. O que o AC 1 exige de fato — evento não-terminal, direção segue marcada como rodando, Stop habilitado, argsdev/attempt/max/detail— está atendido.static/i18n.js— chave novaerror.output_retryem PT e EN;error.playreescrita (eraplay(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 — owrite()abortado peloclose()não emite nem envenena o contador de um sink que nem vai sobreviver ao Stop.Gate (HANDBOOK §6) — resultado real
python -m compileall -q .→COMPILE_OK;import fase0_poc, laguna_core, laguna_server→IMPORTS_OK(C:\Python313\python.exe).laguna_core.py).test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto: rodou emcuda/float16,out_gate.wavgerado, 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.static/).node --check static/app.jsestatic/i18n.js→JS_OK; paridade PT/EN pelo teste versionadotests_unit/test_i18n_parity.py→ passou.pytest tests_unit/ -q→ 58 passed (54 antes + 4 novos).Novos em
tests_unit/test_output_sink.py: falha isolada érecoverablee não para a direção;OUT_SINK_MAX_ERRORSconsecutivas 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 exigindoerrors == []). Tudo com stream falso — sem PortAudio, device, modelo ou GPU: roda no jobtests-unitda CI.Riscos
on_error(_OutputSinké privado;grepno repo confirma que os únicos consumidores sãolaguna_core.pyetests_unit/test_output_sink.py, ambos atualizados neste PR)./api/startjá 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.devcontinua sendo o índice do PortAudio, não um rótulo legível — mantido como estava para não chamarquery_devices()dentro de um caminho de erro. Melhoria de UX possível em issue própria.error.playmantémdev/detaile ganhamax. Cliente antigo que ignorerecoverablevolta ao comportamento de hoje (trata como terminal) — degradação segura.Solicito quórum (HANDBOOK §7)
Closes #54