From 05a698a83e75fdb0fb6beee7ba7e3b8a979f3747 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 28 Aug 2026 08:32:41 +0000 Subject: [PATCH] analista: il motivo di un guasto si prende da stdout E da stderr, o si perde MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il 2026-08-28 alle 00:37:00Z la CLI `claude` e' uscita 1 scrivendo «Failed to authenticate: OAuth session expired and could not be refreshed» su STDOUT, con stderr VUOTO. `interroga()` componeva il motivo dal solo stderr, quindi nel DB e' finito `analisi_stato='errore'`, `analisi_motivi='uscita 1: '` — e su Telegram e' partito «analisi non disponibile (errore): uscita 1:». La meccanica ha retto (nessuna analisi di ieri spacciata per quella di oggi, esito registrato, notifica partita): a mancare era solo il PERCHE'. P4 — un'allerta risponde a due domande, e con la prima sola la causa si ricostruisce aprendo a mano il transcript della sessione headless. P3 — quando la diagnosi serve, il guasto e' gia' rientrato: il motivo si cattura li' o mai. - `motivo_uscita(returncode, stdout, stderr)`: unisce i due canali (stderr per primo), tronca a 300 caratteri. - Silenzio totale su entrambi i canali -> lo 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 e finora si scrivevano uguali. - 4 test nuovi, fra cui la regressione col messaggio testuale del 28/08. NON risolve la causa a monte: perche' quella refresh sia fallita non sta in nessun log locale. Il refresh token era valido (rinnovo automatico riuscito alle 08:03:35Z dello stesso giorno, scadenza 2026-09-25) e la stessa chiamata rifatta a mano esce 0 -> guasto transitorio, unico su 24 sessioni locali. Da qui in avanti, se ricapita, il motivo si legge dalla notifica. Co-Authored-By: Claude Opus 5 (1M context) --- src/live/analista.py | 19 ++++++++++++++++++- tests/test_analista.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) 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