What changed, and why
A deterministic bug — a dict where a Rule was expected — once made the enrich activity retry 500+ times with no signal that anything was wrong. This change closes both halves of that failure: the retrying that never gave up, and the silence while it happened.
Two coupled pieces. First, the retry policy now gives up on a wall-clock budget rather than an attempt count, and fails a wider set of bug-shaped exceptions immediately. Second, when an activity does fail for good, a W2 hook pushes one phone alert (via ntfy) naming the activity and payee, then lets the transaction fail visibly. The alert is immediate but deduped by a durable ledger, so it can't bombard — a single broken transaction pages once a day, a systemic break pages a few times then quiets.
Why a wall-clock budget, not maximum_attempts
This is the load-bearing decision, so it carries a long comment. The trap: per-attempt cost in this system spans ~1000x. A fast-fail (YNAB down, Ollama unreachable) fails in milliseconds; a hung model attempt runs to its 1200 s (20 min) request timeout before it fails. So one attempt count means radically different real-time windows — maximum_attempts=30 is ~25 min for the fast case but up to ~10 hours for a hung model, and that spread exists even within the single enrich activity.
schedule_to_close is the bound; 45 min is 'how long an outage lasts before you get paged'.
src/ynab_agent/workflow/constants.py · 119 lines
⋯ 32 lines hidden (lines 1–32)
⋯ 46 lines hidden (lines 74–119)
The denylist that fails a bug fast
Temporal retries by default, so the only lever is a denylist of exception type names that should not retry. The mechanism already existed; the gap was that AttributeError — exactly what the #4 bug raised — wasn't on it, so a deterministic crash was treated as a transient blip. The list now covers the common bug classes, and NotImplementedError is here too because the W4/W6 activity stubs raise it.
Denylist + the policy. Attempts stay unbounded — the budget is the bound.
src/ynab_agent/workflow/constants.py · 119 lines
⋯ 74 lines hidden (lines 1–74)
⋯ 13 lines hidden (lines 107–119)
The dedup ledger: immediate, but never a flood
"Alert on every failure" and "don't bombard me" meet in two pure rules. A per-key cooldown: the same transaction id, re-failing every hourly poll tick, alerts once per 24h, not hourly. A global rate cap: independent of any key, no more than ~5 alerts fire in a rolling hour, so a systemic break across many distinct transactions pings a handful of times then goes quiet.
should_notify: the cooldown check, then the rate-cap count. record prunes to keep the tail bounded.
src/ynab_agent/alert/ledger.py · 90 lines
⋯ 54 lines hidden (lines 1–54)
This is pure and frozen like the rest of the domain; the durable AlertLedgerWorkflow wraps it as Temporal state, signalling record after a send and querying should_notify before one — the same shape the rule registry has over its folds.
The W2 hook: page once, then re-raise
The transaction loop is wrapped so a terminal activity failure is caught, alerted, and re-raised — the transaction still fails and stays visible in Temporal. The catch is narrow on purpose: only ActivityError (a non-retryable bug, or the budget elapsing). continue_as_new raises a different exception, so the normal restart path sails through untouched.
try around the loop; except ActivityError -> alert -> raise. _alert_context names the payee.
src/ynab_agent/workflow/txn_workflow.py · 506 lines
⋯ 167 lines hidden (lines 1–167)
⋯ 302 lines hidden (lines 205–506)
Pure: composes the push text from replay-safe fields off the error (the activity name and cause).
src/ynab_agent/workflow/alerting.py · 43 lines
⋯ 17 lines hidden (lines 1–17)
The alert activity: best-effort, never raises
This runs while a transaction is already failing, so its prime directive is to never become a new failure. The whole body is guarded: a dedup check, a send, and a record — and any error in them is logged and swallowed. Because it never raises, Temporal sees success and never retries it, so the push happens at most once. A missed page is acceptable; an alert path that raises (masking the real failure) or loops is not.
Suppressed? return. Unconfigured? log and return. Send off the event loop, then record — never raise.
src/ynab_agent/workflow/alert_activities.py · 123 lines
⋯ 30 lines hidden (lines 1–30)
⋯ 65 lines hidden (lines 59–123)
Proof: one terminal failure, one push
The end-to-end test runs the real workflow on the time-skipping server with a mock enrich that raises a non-retryable error and the real alert_failure activity wired to a fake ntfy backend and a real ledger. It asserts the transaction still fails, and that exactly one push went out naming the activity, the payee, and the cause — the whole path, not a mock of it.
Real alert_failure + fake backend + real ledger; assert the txn fails AND one push carries enrich/Blue Bottle/ValueError.