Skip to content

v1.21.1 — Replay-safe HITL loops: what you approve is exactly what executes (ADR-092)

Choose a tag to compare

@jgouviergmail jgouviergmail released this 02 Jul 21:49

🎯 Fix release — trustworthy draft editing & bulk confirmations

LangGraph resume semantics re-execute the entire interrupted node: past interrupt() calls return their cached values, but every other statement — LLM calls, provider API calls — runs live again. Both HITL flows that looped around interrupt() inside a node therefore replayed their side effects on every user decision:

  • Draft editing re-ran every past DraftModificationService.modify() LLM call (non-deterministic): after two edits and a confirm, the email actually sent could differ from the last version you saw and approved, at O(n²) LLM cost.
  • FOR_EACH bulk confirmation re-ran the provider pre-execution (real API calls) and every past LLM item-filter call: the list executed could diverge from the list you confirmed.

Both now follow the normative ADR-092 pattern — one interrupt() per node execution, loop state flowing through the checkpointed graph state, iteration via conditional self-loop edges (new dedicated for_each_confirm node) — proven by compiled replay harnesses (real nodes + real routers + InMemorySaver + Command(resume=...)).

Invariant: what you last saw is exactly what executes, and no LLM or provider side effect ever runs more than once per decision.

✨ Draft-editing UX (found during in-app validation)

  • One chat bubble per re-presented draft — the run-scoped SSE message_id made every revision overwrite the previous assistant bubble; it now derives from the LangGraph Interrupt.id. Live rendering finally matches the archived history.
  • "Sign with my name" works — a new shared resolve_user_display_name() flows the profile first name from every entry point (web, scheduled actions, Telegram) into the drafting and modification prompts. No more [Your name] placeholders, no invented names; no signature unless you ask.
  • Clarify questions are displayed — the generated clarification question was logged then dropped; it is now persisted, carried on the next interrupt payload and streamed verbatim (with a 6-language generic fallback).

🛡️ Robustness & observability

  • CancelledError propagates at both parallel-executor stages (Python 3.12: it is a BaseException and escaped the Exception filter) — closing the stream actually cancels in-flight bulk work.
  • on_item_error="stop" forces sequential execution so "stop" can stop (FOR_EACH_STOP_FORCES_SEQUENTIAL, default true).
  • HTTP metric labels bounded to route templates (+ unmatched for bot scans, {id}-collapsing fallback for the in-progress gauge). ⚠️ Grafana queries filtering on raw paths must switch to templates.
  • Token-tracking run-records bounded (RUN_RECORDS_MAX_RUNS); place-card/birthday/email/voice timestamps computed in the user timezone; quiet prod boot (Telegram set_webhook flood retry, "system" embedding skip).

📚 Docs & packaging

ADR-092 (new) + index cross-references, HITL.md v8.5, ARCHITECTURE_LANGRAPH.md §2.6/§2.8, GRAFANA_DASHBOARDS.md label warning, knowledge base, HOW §9.5 / WHY §8.4 guides and in-app FAQ changelog in all 6 languages.

No schema change, no migration. One new optional env var: FOR_EACH_STOP_FORCES_SEQUENTIAL.
Quality: Ruff / Black / MyPy strict clean (863 files); 8501 backend unit tests green (+32 new, incl. two compiled replay harnesses); every commit passed the full pre-commit hook.

Full changelog: https://github.com/jgouviergmail/LIA-Assistant/blob/main/CHANGELOG.md