diff --git a/src/live/analista.py b/src/live/analista.py index e20e73f..3eefc3a 100644 --- a/src/live/analista.py +++ b/src/live/analista.py @@ -149,6 +149,23 @@ def costruisci_prompt(pagina: str, storico: str) -> str: f"Scrivi ora l'analisi, e nient'altro.") +def motivo_uscita(returncode: int, stdout: str | None, stderr: str | None) -> str: + """Il motivo di un'uscita non-zero della CLI, preso da stderr E da stdout. + + PERCHE' ENTRAMBI. Il 2026-08-28 alle 00:37Z la CLI `claude` e' uscita 1 scrivendo + «Failed to authenticate: OAuth session expired and could not be refreshed» su STDOUT, + con stderr VUOTO. Leggendo il solo stderr il motivo registrato nel DB e spedito su + Telegram fu `uscita 1: `, cioe' niente: l'allerta diceva il COSA e non il PERCHE' (P4), + e per risalire alla causa e' servito aprire a mano il transcript della sessione headless. + Quando la diagnosi serve il guasto e' gia' rientrato (P3): il motivo si cattura qui o mai. + + E se non c'e' NESSUN messaggio lo si dice, invece di lasciare i due punti a vuoto: + "la CLI non ha detto perche'" e "il perche' l'abbiamo perso noi" sono guasti diversi. + """ + det = " | ".join(x for x in ((stderr or "").strip(), (stdout or "").strip()) if x) + return f"uscita {returncode}: {det[:300] if det else 'nessun messaggio su stderr ne stdout'}" + + def interroga(prompt: str, modello: str = MODELLO_DEFAULT, timeout: int = TIMEOUT_S) -> tuple[str, str | None]: """Ritorna (testo, errore). L'errore si RESTITUISCE, non si ingoia: senza, il giorno che @@ -163,7 +180,7 @@ def interroga(prompt: str, modello: str = MODELLO_DEFAULT, except subprocess.TimeoutExpired: return "", f"timeout dopo {timeout}s" if p.returncode != 0: - return "", f"uscita {p.returncode}: {(p.stderr or '').strip()[:200]}" + return "", motivo_uscita(p.returncode, p.stdout, p.stderr) return pulisci(p.stdout), None diff --git a/tests/test_analista.py b/tests/test_analista.py index eb6d9bc..c97f530 100644 --- a/tests/test_analista.py +++ b/tests/test_analista.py @@ -139,7 +139,40 @@ def test_uscita_non_zero_ritorna_errore(monkeypatch): class R: returncode, stdout, stderr = 2, "", "boom" monkeypatch.setattr(subprocess, "run", lambda *a, **k: R()) - assert "uscita 2" in A.interroga("x")[1] + err = A.interroga("x")[1] + assert "uscita 2" in err and "boom" in err + + +# Il guasto vero del 2026-08-28 00:37Z: la CLI e' uscita 1 scrivendo il motivo su STDOUT con +# stderr VUOTO. Il motivo registrato fu `uscita 1: ` — l'allerta diceva il COSA e non il PERCHE' +# (P4), e la causa e' stata ricostruita solo aprendo a mano il transcript della sessione headless. +# Questo test blinda che quel messaggio arrivi fino al DB e a Telegram. +def test_motivo_preso_da_stdout_quando_stderr_e_vuoto(monkeypatch): + class R: + returncode = 1 + stdout = "Failed to authenticate: OAuth session expired and could not be refreshed" + stderr = "" + monkeypatch.setattr(subprocess, "run", lambda *a, **k: R()) + testo, err = A.interroga("x") + assert testo == "" + assert "OAuth session expired" in err and "uscita 1" in err + + +def test_motivo_tiene_entrambi_i_canali(): + err = A.motivo_uscita(1, "detto su stdout", "detto su stderr") + assert "detto su stderr" in err and "detto su stdout" in err + + +def test_silenzio_totale_si_dichiara_invece_di_lasciare_i_due_punti_a_vuoto(): + """'la CLI non ha detto perche'' e 'il perche' l'abbiamo perso noi' sono guasti diversi.""" + err = A.motivo_uscita(1, "", None) + assert not err.rstrip().endswith(":") + assert "nessun messaggio" in err + + +def test_motivo_lungo_troncato_ma_non_vuoto(): + err = A.motivo_uscita(1, "x" * 5000, "") + assert len(err) < 400 and "x" in err # ------------------------------------------------- il DB: cosa scrive e cosa non tocca