diff --git a/crates/btv-cli/src/btv_agent.rs b/crates/btv-cli/src/btv_agent.rs index 2ab28e9..9f3623a 100644 --- a/crates/btv-cli/src/btv_agent.rs +++ b/crates/btv-cli/src/btv_agent.rs @@ -456,7 +456,9 @@ fn spawn_status_watcher(state: BtvAgentState, task_id: String, ctx: btv_domain:: let now = crate::session::now_rfc3339(); { let store = state.store.lock().unwrap_or_else(|e| e.into_inner()); - let _ = store.set_status(&task_id, status, &now); + if let Err(e) = store.set_status(&task_id, status, &now) { + eprintln!("btv: falha ao persistir status final do run ({task_id}): {e}"); + } } // Entregas (U4): só em conclusão limpa — run com erro/encerrado não // "entrega". A escrituração vive em `registrar_entregas`, extraída para diff --git a/crates/btv-cli/src/squad_agent.rs b/crates/btv-cli/src/squad_agent.rs index 41b0123..3617bc5 100644 --- a/crates/btv-cli/src/squad_agent.rs +++ b/crates/btv-cli/src/squad_agent.rs @@ -715,7 +715,9 @@ where } } } - let _ = ledger_session.finish(failure.is_none(), 1); + if let Err(e) = ledger_session.finish(failure.is_none(), 1) { + eprintln!("btv: falha ao finalizar a sessão do squad no ledger: {e}"); + } core_task.abort(); match failure { None => Ok(()), diff --git a/crates/btv-cli/src/web_agent.rs b/crates/btv-cli/src/web_agent.rs index e07c871..cc5a88b 100644 --- a/crates/btv-cli/src/web_agent.rs +++ b/crates/btv-cli/src/web_agent.rs @@ -362,7 +362,9 @@ fn finish_task_ok( ledger_session: &mut crate::session::Session, steps: usize, ) { - let _ = ledger_session.finish(true, steps); + if let Err(e) = ledger_session.finish(true, steps) { + eprintln!("btv: falha ao finalizar a sessão no ledger ({session_id}): {e}"); + } let verified = ledger_session.verify().unwrap_or(0); // Libera ANTES de anunciar: `Done` é o sinal de "pode mandar a próxima" // — publicá-lo com a sessão ainda `busy` abria uma janela de 409 para o diff --git a/crates/btv-llm/src/gateway.rs b/crates/btv-llm/src/gateway.rs index 5b2a561..60d7569 100644 --- a/crates/btv-llm/src/gateway.rs +++ b/crates/btv-llm/src/gateway.rs @@ -222,4 +222,79 @@ mod tests { .unwrap_err(); assert!(matches!(err, GatewayError::NoProvider)); } + + fn req_simples() -> GenerateRequest { + GenerateRequest { + model: "x".into(), + system: String::new(), + messages: vec![], + tools: vec![], + max_tokens: 16, + temperature: None, + } + } + + /// T3 — falha REAL de transporte (conexão recusada), não mock: um provider + /// apontado a uma porta fechada vira `AllFailed` (a cadeia de fallback tenta + /// e reporta), nunca pendura nem entra em pânico. + #[tokio::test] + async fn conexao_recusada_vira_all_failed() { + // Porta livre obtida e liberada na hora → conexão recusada determinística. + let porta = { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let gw = Gateway { + client: reqwest::Client::new(), + providers: vec![ProviderConfig { + id: ProviderId::Anthropic, + api_key: "k".into(), + base_url: format!("http://127.0.0.1:{porta}"), + }], + }; + let err = gw.generate(req_simples(), &mut |_| {}).await.unwrap_err(); + let GatewayError::AllFailed(msg) = err else { + panic!("esperava AllFailed em conexão recusada"); + }; + assert!( + msg.contains("anthropic"), + "mensagem deve nomear o provider: {msg}" + ); + } + + /// T3 — provider que ACEITA a conexão mas nunca responde: o timeout do + /// cliente HTTP corta em vez de esperar para sempre (o bug de produção que + /// os timeouts do gateway resolveram — squad "ativa" zumbi). Vira `AllFailed` + /// rápido, sem pendurar. + #[tokio::test] + async fn servidor_que_pendura_estoura_timeout_sem_pendurar() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let porta = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + // Aceita e segura o socket sem escrever resposta HTTP. + if let Ok((sock, _)) = listener.accept().await { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + drop(sock); + } + }); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_millis(400)) + .build() + .unwrap(); + let gw = Gateway { + client, + providers: vec![ProviderConfig { + id: ProviderId::Anthropic, + api_key: "k".into(), + base_url: format!("http://127.0.0.1:{porta}"), + }], + }; + let inicio = std::time::Instant::now(); + let err = gw.generate(req_simples(), &mut |_| {}).await.unwrap_err(); + assert!( + inicio.elapsed() < std::time::Duration::from_secs(5), + "deveria cortar no timeout curto, não esperar o servidor" + ); + assert!(matches!(err, GatewayError::AllFailed(_))); + } } diff --git a/docs/documentacao/mapeamentos/04-cobertura-de-testes.md b/docs/documentacao/mapeamentos/04-cobertura-de-testes.md index 60c9b33..3801429 100644 --- a/docs/documentacao/mapeamentos/04-cobertura-de-testes.md +++ b/docs/documentacao/mapeamentos/04-cobertura-de-testes.md @@ -25,7 +25,7 @@ Playwright) + análise de código. | btv-domain | U | ✅ (agregado, máquina de status, round-trip) | ✅ transições inválidas, tenant vazio | baixo | | btv-schemas | U, P, G, B | ✅ | ✅ `NumeroProibido`, paridade de hash, fixtures | baixo | | btv-core | U | ✅ loop completo <100ms, negação, desconhecida, truncado | ✅ `MaxSteps`, deny | baixo | -| btv-llm | U, B | ✅ agregadores, tier, rate-limit | ⚠️ falha de rede real do provedor não é unit-testável (mock) | médio | +| btv-llm | U, B | ✅ agregadores, tier, rate-limit | ✅ falha REAL de transporte (conexão recusada) e timeout (servidor que pendura, corta rápido) → `AllFailed` | baixo | | btv-tools | U, I (`loop_com_ferramentas_reais`, `mcp_integration`, `lsp_integration`) | ✅ tools reais, MCP/LSP contra servidor real | ⚠️ sandbox Docker: testes `#[ignore]` (só no job `sandbox`) | médio | | btv-store | U, C (`contract_sqlite`, `contract_pg`), I (migrações, replay) | ✅ | ✅ `BrokenChain`/`ForeignEntry`/`Conflict`, RLS adversarial, retry concorrente | baixo | | btv-verify | U, G (`schema_golden`) | ✅ pipeline, vetter, kill de grupo | ✅ timeout, block fail-closed | baixo | @@ -59,7 +59,7 @@ Playwright) + análise de código. |---|---|---|---|---| | `btv-cli::web_agent` ramos de erro (permissão timeout, 409 ator único, sessão morta) | e2e parcial | **alto** | unit do `SessionHub` p/ timeout→Deny e single-actor | real | | `btv-cli::squad_agent` (cockpit inject, HITL expirado, emergency-stop) | e2e | **médio-alto** | teste do `inject_cockpit_context` e do gate obsoleto | real | -| `Gateway` falha real de rede/timeout do provedor | mock | médio | teste com servidor HTTP fake lento (timeout) | real | +| `Gateway` falha real de rede/timeout do provedor | ✅ unit (conexão recusada + servidor que pendura c/ timeout curto) → `AllFailed` | baixo | coberto (`gateway.rs`) | real | | `Sandbox` contenção (escape, rede, mem) | `#[ignore]` (job `sandbox` com Docker) | médio | já existe — exige daemon | real | | `PgStore` sob contenção extrema (>64 retries) | contract_pg | baixo-médio | teste que force >64 adversários | teórico (defesa) | | `BrokenChain` real em produção | unit | baixo | já coberto | teórico (não deve ocorrer) |