analista: il motivo di un guasto si prende da stdout E da stderr, o si perde

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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-28 08:32:41 +00:00
parent a6b5756cc0
commit 05a698a83e
2 changed files with 52 additions and 2 deletions
+18 -1
View File
@@ -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
+34 -1
View File
@@ -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