Skip to content

Release v0.106.0 — duplicates judged by what the room heard; no stall on an empty list - #105

Merged
tesseractAZ merged 1 commit into
mainfrom
fix/ami-list-error-and-content-dedupe
Sep 22, 2026
Merged

tesseractAZ merged 1 commit into
mainfrom
fix/ami-list-error-and-content-dedupe

Conversation

@tesseractAZ

Copy link
Copy Markdown
Owner

Closes the two items left open after v0.105.2.

1. The same announcement could play twice — or never

"Duplicate" was answered from one fact the web UI held in memory: that it had itself sent identical audio. Past that, the same words reached a room twice (the first copy was a scheduler replay, went out while the state read could not judge, or played before a restart); inside it, "duplicate" was answered after the room had since been asked for something different, or when the first copy never played.

delivery.content_verdict() now sorts every earlier ask to that room into heard / in flight / waiting / gone and answers ("heard", row), ("pending", row) or None. A retirement is deliberately not "gone": the retry may retire a clip 20 s after an attempt whose call is still playing. The web UI uses the ledger whenever it can answer and the in-memory window only when it cannot (unreadable, no rows for that room, no key, window off). Neither duplicate row is an ask: a repeat never displaces the copy the room is already owed and never retires anything.

Rows name content by a keyed tag (HMAC, key in /data), never a plain hash of predictable speech in the shared folder, and the handset clips are no longer served to the LAN.

2. No registered phone meant a 2.5 s poll

Asterisk answers an empty contact list with Response: Error and nothing more, so the read waited for a Complete that never came — every dashboard, console and link-monitor poll took its full timeout exactly when a whole-house outage made it most useful. An error that is an action's first reply now ends it; a mid-list error still waits for the Complete. Both single list reads are tagged, stream_complete is retired, and every login sends Events: off. Also: a trunk refusal is no longer read as "no trunk configured", a refused registration kick is no longer reported as sent.

Verification

  • 951 tests; a 35-mutant battery over every behavioural change, no survivors.
  • Live against the production PBX: every read path works with Events: off (status bundle 4.8 ms, endpoints 1.3 ms, registrations 0.5 ms), and a real error-first reply ends in 0.3 ms instead of waiting 2.5 s.
  • Three adversarial review rounds (52 agents). Round 2 caught a lockout this release would otherwise have shipped: when a pending duplicate was a retry candidate, an alert repeating faster than ~41 s minted a fresh candidate each time and the room heard nothing at all. Pending rows became records; simulations across cadences from 20 s to 300 s, with the retry on and off, now all produce audio.

🤖 Generated with Claude Code

… on an empty list

An identical announcement was answered "duplicate" only from the web UI's own
memory of having sent one. That was wrong both ways: the same words reached a
room twice when the first copy was a scheduler replay, went out unjudged, or
played before a restart; and "duplicate" was answered after the room had since
been asked for something different, or when the first copy never played.

- delivery.content_verdict(): sorts every earlier ask to a room into HEARD /
  IN FLIGHT / WAITING / GONE and answers ("heard", row), ("pending", row) or
  None. A retirement is not "gone" — the retry may retire a clip 20 s after an
  attempt still playing. "Pending" only counts while a replay is possible.
- app.py: the ledger governs when it can answer; the in-memory window decides
  when it cannot (unreadable, no rows for that room, no key, window off).
  Rows carry a keyed content tag (HMAC, key in /data), never a plain hash of
  predictable speech in the shared folder.
- Neither duplicate row is an ask: a repeat never displaces the copy the room
  is already owed, and never retires anything. The scheduler's retry retires a
  waiting replay as content-delivered.
- ami.actions_complete(): a list action also finishes when its FIRST reply is
  an error — how Asterisk answers an empty contact or endpoint list — so a
  poll with no phone registered no longer waits out its timeout. A mid-list
  error still waits for the Complete. Both single list reads are tagged now;
  stream_complete is retired; every login sends Events: off.
- A trunk refusal is no longer read as "no trunk configured", a refused
  registration kick is no longer reported as sent, and the LAN /announce
  exemption is narrowed to the speaker clips that need it.

951 tests; 35-mutant battery, no survivors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tesseractAZ
tesseractAZ merged commit 01d32fc into main Sep 22, 2026
10 checks passed
@tesseractAZ
tesseractAZ deleted the fix/ami-list-error-and-content-dedupe branch September 22, 2026 18:16
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.

1 participant