Skip to content

fix(generative): Timeout-Retry im legacy-Profil + verlorene Konzepte sichtbar (#210)#216

Merged
TillQuandel merged 3 commits into
masterfrom
fix/210-cli-timeout-retry
Jul 12, 2026
Merged

fix(generative): Timeout-Retry im legacy-Profil + verlorene Konzepte sichtbar (#210)#216
TillQuandel merged 3 commits into
masterfrom
fix/210-cli-timeout-retry

Conversation

@TillQuandel

@TillQuandel TillQuandel commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Problem (#210)

Das Subscription-Backend (claude CLI) verlor bei einem 300s-Timeout Konzepte auf zwei Wegen — beide belegt in generative/.cache/runs/:

  1. Planner-Timeout killt den Lauf. runtime-config zeigte timeout_retries=0 fürs legacy-Profil → ein transienter CLI-Hänger warf RuntimeError, der Orchestrator starb mit Traceback (Exit 1) inkl. bereits gelaufener Stages 1–4.
  2. Extractor-Timeout wird still verschluckt. Planner plant 2 Konzepte, Extractor-Call 1 stirbt am Timeout (Error-Record im Trace, 0 Tokens), Call 2 läuft. Ergebnis: === Fertig: 1 Notes ===, Exit 0, keine Warnung — das verlorene Konzept war nur im Trace sichtbar.

Fix-Ansatz

1. Retry-Budget auch im legacy-Profil. LEGACY.timeout_retries 0 → 1 in runtime_config.py. fast/balanced erben den Default; quality hatte schon 1. Die Retry-Schicht bleibt bewusst in der Config, NICHT im Backend — der bestehende Backend-Kontrakt (test_sub_call_full_does_not_retry_timeout_by_default: übergebenes timeout_retries=0 wird treu geehrt) bleibt intakt. Das Backend spawnt pro Versuch bereits einen frischen CLI-Prozess (subprocess.run / create_subprocess_exec in der Retry-Schleife) und gilt für sync- UND async-Pfad.

2. Kein stilles Verschlucken + Exit-Code. run_extractors_per_concept trennt jetzt harte Call-Ausfälle (Exception: Timeout/CLI-Fehler nach Retries) von legitimen Leer-Extraktionen (run_per_conceptNone, Konzept zu schwach im Text) und gibt sie als failures-Liste zurück. main() druckt einen Warn-Block direkt im Run-Summary („N von M Konzepten beim Extrahieren verloren …") und beendet den Prozess mit einem eigenen Exit-Code.

Exit-Code-Semantik: 0 = voller Erfolg, 1 = harter Abbruch (unbehandelte Exception, z.B. Planner-Timeout nach Retries → kein Plan), 3 = Lauf abgeschlossen, aber ≥1 Konzept endgültig verloren. 3 hält den Teilverlust von einem harten Absturz unterscheidbar (CLI/GUI/CI können reagieren). __main__ propagiert via sys.exit(main()); cli.py reicht orchestrator.main() or 0 durch.

pipeline_runs: Kein Schema-Change. n_dropped existiert bereits und zählt Ausfälle schon mit (dropped_total = Fehler + Leer-Extraktionen) → das Dashboard sieht den Verlust in der Aggregat-Zahl.

Konfigurierbares Call-Timeout (#210 Punkt 3): bereits vorhanden via ATOMIC_AGENT_CALL_TIMEOUT (runtime_config → base.py → Backend) — nichts zu tun.

Bewusst NICHT enthalten (separate PRs): Stage-Outcome-Events (#197), pipeline_runs-Insert-Entkopplung von der Eval (#198).

RED-Beweis (TDD)

Vor dem Fix:

  • test_orchestrator_timeout_visibility.pyImportError: cannot import name 'format_extractor_failure_report' (Helfer/4-Tupel existierten nicht).
  • test_legacy_profile_retries_transient_timeout_onceAssertionError: assert 0 >= 1 (RuntimeConfig(...timeout_retries=0...)).

Nach dem Fix: beide grün. Backend mockt Timeout beim 1. Call + Erfolg beim Retry / Timeout bei allen Calls (keine echten CLI-/LLM-Calls).

Suite-Ergebnis

pytest generative lib/decision_engine/tests shared/tests -q5702 passed, 3 skipped, 0 failed (inkl. 9 neuer Tests aus diesem PR). ruff format --check . und ruff check . grün.

Cross-Model-Review

Mistral magistral-medium-latest (voller Diff): keine echten Funde. Gezielt geprüft und als korrekt bestätigt: 4-Tupel-Entpackung (by-chapter akkumuliert failures, single-doc reassigned), Exit-Code-Pfade (0/1/3 inkl. No-Drafts → leere Failures → Exit 0), Config-Änderung. Keine Regressionen/Race-Conditions.

Closes #210

Nachbesserung (Fable-Kontrollreview, Mistral-Resample + Qwen ohne weitere Funde)

Nebeneffekt dokumentiert: timeout_retries erreicht auch das litellm-Backend. _backend_runtime_kwargs() (agents/base.py) reicht timeout_retries unterschiedslos an beide Backends durch — nicht nur ans Subscription-/CLI-Backend. Im litellm-Pfad (_litellm_backend.py:_resolve_num_retries) wird derselbe Wert 1:1 zu num_retries gemappt, das dort aber eine breitere Fehlerklasse abdeckt als beim CLI-Backend (dort ausschließlich subprocess.TimeoutExpired): litellm retryt bei Timeout UND APIError UND APIConnectionError einheitlich (Doku-String in _resolve_num_retries, Parität #148). D.h. das legacy-Profil-Retry-Budget (0→1, dieser PR) senkt nicht nur die Timeout-Toleranz des CLI-Pfads, sondern erhöht im litellm-Pfad zusätzlich die Toleranz gegenüber generischen API-/Verbindungsfehlern. Bewusster Nebeneffekt der gemeinsamen Config-Quelle, kein Bug — aber kein reines "CLI-Timeout-Feature", wie der PR-Titel nahelegen könnte.

GUI: Exit-Code 3 (Teilverlust) wurde bislang wie ein Absturz behandelt. generative/gui/static/app.js hat rc===3 im exited-Handler in den generischen Fehler-Zweig fallen lassen: markStageError() färbte die zuletzt aktive Stage (typischerweise die erfolgreich gelaufene Stage 8) rot, das Log meldete „Lauf mit Fehlercode 3 beendet.", und loadOutputs() lief nur bei rc===0 — die tatsächlich geschriebenen Notes wurden dem GUI-Nutzer also gar nicht angezeigt. Fix: rc===3 läuft jetzt im selben Zweig wie rc===0 (Stages als erledigt statt Fehler markiert, Outputs geladen, Fokus auf die Ergebnis-Sektion), zusätzlich eine eigene Warnzeile/Banner „Teilverlust: N Konzept(en) beim Extrahieren verloren (Timeout/CLI-Fehler, auch nach Retry) — Notes trotzdem geschrieben." (Wortlaut an format_extractor_failure_report() in orchestrator.py angelehnt; N wird aus der entsprechenden Log-Zeile geparst, fällt ohne Treffer auf eine zahlenfreie Formulierung zurück statt zu raten). Der Verlaufs-Badge (addHistoryEntry) zeigt für rc===3 konsistent „Teilverlust (Timeout)" statt des generischen „Fehlercode 3". Keine dedizierte JS-Testinfrastruktur im Repo vorhanden (kein package.json/jsdom/jest, geprüft) — Verhalten per node --check (Syntax) + Code-Inspektion belegt, kein neues Test-Framework eingeführt.

Testbeleg präzisiert (Punkt 1 oben, test_sub_call_full_does_not_retry_timeout_by_default). Der bestehende Test ruft sub_call_full(...) ohne timeout_retries-Argument auf und prüft damit nur den Modul-Default (_TIMEOUT_RETRIES, zufällig ebenfalls 0). Er hätte einen Bug wie timeout_retries or _TIMEOUT_RETRIES (0 ist falsy → fiele fälschlich auf den Default zurück) nicht erkannt. Neuer Test test_sub_call_full_explicit_timeout_retries_zero_does_not_retry setzt den Modul-Default explizit auf 1 (monkeypatch.setattr(sub_backend, "_TIMEOUT_RETRIES", 1)) und übergibt trotzdem explizit timeout_retries=0 — beweist, dass der explizite Wert den Default überstimmt (call_count == 1, kein Retry). Die is None-Weiche in call_full() war bereits korrekt; der Test hält das jetzt regressionssicher fest.

Suite: pytest generative lib/decision_engine/tests shared/tests -q → 5703 passed, 3 skipped, 0 failed (inkl. 1 neuer Test aus dieser Nachbesserung). ruff format --check . + ruff check . grün.

Nachbesserung Runde 2 (Opus-bestätigter Fund)

Teilverlust-Banner behauptete auch bei 0 geschriebenen Notes „Notes trotzdem geschrieben". rc=3 kann laut Orchestrator-Early-Return (if not drafts: bei nicht-leeren extractor_failures) auch auftreten, wenn ALLE Konzepte verloren gingen und gar keine Note geschrieben wurde — der bisherige Banner-Text war dann falsch. Fix: buildExtractorLossText() erhält jetzt den echten Notes-Count aus loadOutputs()//api/outputs (nicht den geparsten Verlust-Count) und wählt bei writtenCount === 0 die neutrale Formulierung „... — keine Notes geschrieben." statt „... — Notes trotzdem geschrieben."; zusätzlich das Verlaufs-Label für rc=3 von „Teilverlust (Timeout)" auf „Teilverlust (Timeout/CLI-Fehler)" verallgemeinert, da die Ursache auch ein CLI-Fehler sein kann.

…sichtbar (closes #210)

- runtime_config: LEGACY.timeout_retries 0 -> 1 (fast/balanced erben; quality
  hatte schon 1). Ein transienter 300s-CLI-Haenger kostet nicht mehr den ganzen
  Lauf; das Backend spawnt pro Versuch bereits einen frischen CLI-Prozess.
- run_extractors_per_concept: trennt harte Call-Ausfaelle (Timeout/CLI-Fehler
  nach Retries) von legitimen Leer-Extraktionen und gibt sie als failures zurueck.
- orchestrator.main: Warn-Block im Run-Summary ('N von M Konzepten verloren') +
  Exit-Code 3 (unterscheidbar von hartem Abbruch=1). Voller Erfolg bleibt 0.
  __main__ propagiert den Code via sys.exit(main()).
- n_dropped (pipeline_runs) zaehlt Ausfaelle bereits mit -> kein Schema-Change.
- ATOMIC_AGENT_CALL_TIMEOUT (konfigurierbares Call-Timeout) war bereits verdrahtet.
…achbesserung)

rc===3 (orchestrator.py:_EXIT_EXTRACTOR_LOSS) heisst "Lauf abgeschlossen,
aber >=1 Konzept beim Extrahieren verloren" -- kein Absturz. Bisher lief das
im generischen Fehler-Zweig: markStageError() faerbte die zuletzt aktive
Stage (typisch die erfolgreich gelaufene Stage 8) faelschlich rot, und
loadOutputs() lief nur bei rc===0, sodass die real geschriebenen Notes dem
GUI-Nutzer nicht angezeigt wurden (Fable-Kontrollreview-Fund).

rc===3 laeuft jetzt im selben Zweig wie rc===0 (Stages als erledigt statt
Fehler markiert, Outputs geladen, Fokus auf Ergebnis-Sektion), zusaetzlich
eine eigene Warnzeile/Banner mit an format_extractor_failure_report()
(orchestrator.py) angelehntem Wortlaut inkl. der aus dem Log geparsten
Konzept-Anzahl. Verlaufs-Badge zeigt fuer rc=3 konsistent "Teilverlust
(Timeout)" statt "Fehlercode 3". Keine JS-Testinfrastruktur im Repo
vorhanden (geprueft) -- Verhalten per `node --check` + Code-Inspektion
belegt.

Zusaetzlich: Testluecke geschlossen. Der bestehende Test
test_sub_call_full_does_not_retry_timeout_by_default prueft nur den
Modul-Default (kein timeout_retries-Argument uebergeben). Neuer Test
test_sub_call_full_explicit_timeout_retries_zero_does_not_retry uebergibt
explizit timeout_retries=0 bei auf 1 gesetztem Modul-Default und beweist,
dass der explizite Wert gewinnt (kein Retry).
TillQuandel pushed a commit that referenced this pull request Jul 12, 2026
…/Extractor/Faithfulness-Skip-Events + Dashboard-Event-Robustheit (#197 Nachbesserung)

Extern verifizierte Funde (Mistral-Resample + Fable). Der Gate-Funnel ist oben
dicht: jeder Note-Verlust traegt jetzt ein stage_outcome-Event.

Funnel-Luecken (orchestrator.py, TDD):
- entity_resolution Cluster-Merge (consumed-Set): dedup/dropped/
  entity_resolution_merge, Survivor als detail (strukturell wie sibling-Dedup).
- _collect_stage6_results BaseException-Zweig: droppte ohne Event UND ohne
  Crash-Report — jetzt beides (Crash-Report ohne per-Note-Payload + Event).
- run_extractors_per_concept: empty-ctext (Konzept nicht im Volltext), None
  (leere Extraktion) und Exception (call_failed) → extractor/dropped-Events.
- _apply_faithfulness_gate: Gate aktiv + create, aber leerer page_index (PDF
  ohne [S. N]-Marker) → faithfulness/skipped/no_page_index.

Dashboard-/DB-Event-Robustheit (minimale Hunks):
- eval_dashboard._read_token_runs: Event-Records (kein model) vom call-Zaehler
  ausschliessen — verhindert aufgeblaehte calls + 0-Token-Phantomzeile.
- eval_dashboard_server model-Backfill: Scan-Fenster 5 -> 50 Zeilen (Events am
  Trace-Anfang tragen kein model).
- db.init_db: PRAGMA busy_timeout=5000 (kein WAL-Umbau) + _add_column-Helper,
  der nur 'duplicate column name' verschluckt und andere OperationalError
  (z.B. 'database is locked') re-raised.

Tests: 8 neu (5 orchestrator TDD in test_stage_outcome_events.py, 3 in neuem
test_stage_outcome_robustness.py). Suite 5705 -> 5713 passed, 0 failed.
ruff format/check clean.

Konflikt: #216 fasst run_extractors_per_concept Exception-Zweig an (failures) —
beim Merge beide Zeilen behalten. #222/#212 konfliktfrei zu Punkt 5/6.
…es (#210 Runde 2)

rc=3 kann auch mit 0 geschriebenen Notes auftreten (orchestrator.py
Early-Return `if not drafts:` bei nicht-leeren extractor_failures --
alle Konzepte verloren). Banner-Text haengt jetzt vom echten
Notes-Count aus loadOutputs()/api/outputs ab statt pauschal 'Notes
trotzdem geschrieben' zu behaupten. rcLabel fuer rc=3 im Verlauf
verallgemeinert auf 'Teilverlust (Timeout/CLI-Fehler)', da die
Ursache auch ein CLI-Fehler sein kann.
@TillQuandel TillQuandel merged commit e1226f6 into master Jul 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Subscription-Backend: CLI-Timeout (300s) killt Läufe bzw. verschluckt Konzepte still — kein Retry-Budget, Exit 0 trotz Verlust

2 participants