What changed, and why
PR #17 put the W7 balancer's coverage offer on the W6 overspend-alert thread, so the alert and its offer were one conversation. One gap remained: a worsening mid-period re-alert still opened a brand-new thread. The balancer runs one workflow per category-period (REJECT_DUPLICATE) and stays indexed by the first alert's thread, so a reply on the new thread had no BalanceThreadId to match and was orphaned — misrouted to the command path. The conversation forked again.
This change makes one thread per overspend, full stop. A worsening re-alert now replies an update on the existing alert thread instead of opening a new one, so the balancer's BalanceThreadId always matches and a reply always routes back. The fix is a label split in the pure layer, one new mail method, and a one-line change at the monitor's send site. W7 itself is untouched.
Split the label: one stable thread, per-alert dedup
The old single label folded the verdict and projected month-end into the thread key, so each worsening got a different key and a different thread. The fix splits it. overspend_thread_label is now stable for a category-period, so every alert and re-alert resolves to the same thread. A new overspend_update_label keeps the verdict and projected, and becomes the per-alert send-dedup key — distinct namespaces (yaspend- vs yaspend-update-) so the two never collide on the shared thread.
The thread key no longer varies; the update key carries the dedup.
src/ynab_agent/budget/message.py · 83 lines
⋯ 58 lines hidden (lines 1–58)
alert_on_thread: open once, then reply updates
The new mail method carries the open-or-reply logic. First alert: no thread exists for the stable label, so it opens one — tagging the opening message with both the thread label and the first update label, so a retry of that first alert finds the update label already on record and re-sends nothing. A later re-alert: the thread exists, so it replies the update on it (addressed to the owners, because the thread's latest message is the agent's own), deduped on the update label. Either way it returns the one stable thread id.
find(thread) → open with both labels, or reply the update (dedup on update_label).
src/ynab_agent/mail/client.py · 320 lines
⋯ 210 lines hidden (lines 1–210)
⋯ 65 lines hidden (lines 256–320)
The monitor sends through it
send_overspend_alert now calls alert_on_thread with both labels and returns the stable thread id — what W7 replies on, and where every worsening re-alert lands too. The alert dedupe (whether to fire at all) is unchanged: it still lives in should_alert and the durable ledger, separate from the email labels. This change only moved where a re-alert lands, not when one fires.
One send site, both labels, stable id back.
src/ynab_agent/workflow/monitor_activities.py · 159 lines
⋯ 89 lines hidden (lines 1–89)
⋯ 34 lines hidden (lines 126–159)
Tests, and the edges left documented
The regression guard is at the mail layer: open the thread, then a worsening re-alert (new update label, same thread label), and assert both land on the same thread id and the reply is addressed to the owner. Companion tests pin the label semantics (thread stable, update varies, namespaces distinct) and the dedup on both the open and the reply.
first == second: one conversation across the re-alert.