v1.21.1 — Replay-safe HITL loops: what you approve is exactly what executes (ADR-092)
🎯 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_idmade every revision overwrite the previous assistant bubble; it now derives from the LangGraphInterrupt.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
CancelledErrorpropagates at both parallel-executor stages (Python 3.12: it is aBaseExceptionand escaped theExceptionfilter) — 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 (+
unmatchedfor 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 (Telegramset_webhookflood 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