Skip to content

Release v0.105.0 — an announcement whose audio never played is sent again - #102

Merged
tesseractAZ merged 3 commits into
mainfrom
fix/announce-retry
Sep 17, 2026
Merged

tesseractAZ merged 3 commits into
mainfrom
fix/announce-retry

Conversation

@tesseractAZ

Copy link
Copy Markdown
Owner

Summary

Owner decision: an announcement whose audio never played must be retried automatically. This is the third occurrence of the shape — an announcement queued seconds after a restart, before the cordless had re-registered, that died with no channel and no audio (2026-09-15T01:42:12Z).

Where it lives. A third pass in the wake-up scheduler's tick, in its own try/except so a retry that raises can neither stop verdicts being filed nor stop alarm clocks ringing. The scheduler was chosen over the webui because it survives the restart that causes the defect, already reads the ledger and endpoint states, and already originates.

Never replaying audio that played. The retry re-originates the same clip, so the existing clip-name join retires it permanently once an audio-delivered row exists — the retry must not mint a new name, which would break that join. Only the newest announcement queued to a room is a live candidate; older ones retire as ext-superseded, and a per-tick lock means two INVITEs can never race to one handset.

Bounds, from this system's own numbers. 2 attempts, 20 s minimum age, 150 s maximum age, and a positive endpoint state observed twice a poll apart. Every occurrence of this shape was post-restart re-registration, and every endpoint was back within 46 s; two attempts a tick apart straddle that. Attempts are counted from the ledger, so a restart cannot refill the budget. 150 s is pinned against the clip's own 300 s lifetime and is deliberately not the 120 s settling window — at 120 s the second attempt was structurally unreachable.

Off switch that works. announce_retry_attempts (default 2, range 0–3, 0 disables) is wired through config.yaml options and schema, translations, and the run script's export, with a test pinning all four plus the exported name against the one the code reads. The review caught that the original env-only switch could never be set from inside the add-on.

Also: app.py stops arming the 300 s duplicate-suppression window when the pre-flight guard could not judge the endpoint — that window was being armed by an announcement nobody heard.

Testing

  • 870 tests pass (802 on main).
  • 33 mutants killed, plus a control mutant that survived as required.
  • Tests drive the real callers: tick(), _reconcile_announcements, api_announce.
  • Review found 7 issues (2 major) and all were fixed: per-extension serialisation, and the unusable kill switch.
  • Leak scan clean.

Known trade-offs, accepted under the owner's decision

  • A leg that played under a second (below the delivered threshold) writes no delivered row and is replayed.
  • A clip is replayed verbatim up to 150 s after it was rendered; the producer cannot cancel a pending retry.
  • If a newer announcement is queued to the same room, the older one retires unplayed even when the content differs — the alternative is two calls to one handset inside 150 s.

Live verification after deploy

Staged so nothing plays audio in the house: the new option and startup line; then the full candidate path and every refusal branch against ext 20, which has never registered; then the supersession rule. The green-light path on a real handset stays unverified until it happens naturally, and is recorded as such.

🤖 Generated with Claude Code

tesseractAZ and others added 3 commits September 16, 2026 18:31
Third occurrence of one shape: 2026-09-15T01:42:12Z, 8.4 s after the add-on
restarted for a deploy, an announcement was queued to the cordless before that
handset had re-registered. Asterisk logged `Could not create dialog to invalid
URI '19'`, no channel existed, no audio played, and the only thing recorded was
`announce-unsettled` three minutes later. The handset's contact came back 37.9 s
after the send that failed. v0.104.0 shipped the record half; the owner's
decision after it was that such an announcement must be RETRIED automatically.

The retry lives in wakeup/scheduler.py as a third pass in the main loop, in its
own try block: that loop already ticks every 20 s, already reads the ledger with
not_before=_STARTED, already owns the settling window, and — decisively — it
SURVIVES the restart that causes this defect, which a retry held in the webui's
own process would not.

Never replaying audio that played is the hard constraint, and six locks hold it:

1. the clip join that already exists. callqos stamps the same --sound onto the
   row it writes from the hangup extension, so any ANNOUNCE_TERMINAL row for a
   clip excludes it permanently, at any age, in any order — including the
   FILTER-mangled spelling, via clip_key(). ANNOUNCE_TERMINAL is now one tuple
   shared with the reconciler instead of an inline set, so the two cannot drift.
2. two CONSECUTIVE idle observations a poll apart, so a replay cannot land on
   audio in progress.
3. the budget lives in the ledger: announce-retry-attempted rows are counted from
   disk, written BEFORE the Originate, and the Originate is skipped when record()
   returns False. A restart cannot refill it.
4. ext freshness: a newer announcement delivered to the same room retires the
   clip as ext-superseded.
5. positive state only. The green light is ami.device_idle — the single state
   "not in use". Empty, unavailable, ringing, in use and any unrecognised
   spelling all defer. The fail-OPEN pre-flight is what caused the incident and
   is deliberately not reused for a replay.
6. a final narrow veto read AFTER the state read, so a delivered row landing
   mid-decision can only appear, never be missed.

Bounded at 2 attempts and never started later than 150 s after the original
send. 150, not the 120 s settling window: at 120 the second attempt is
structurally unreachable for the ring-out and slow-re-registration shapes (both
driven in the new tests), and the cap is instead derived from the clip's own
300 s life — 150 + 30 ring + 90 clip = 270. Three invariants are pinned by test,
including reading _cleanup_announce_dir's default out of app.py's signature, and
the startup line warns "announce retry is INERT" if an override makes the first
attempt unreachable.

Also here:

* webui/announce_clip.py owns the announce directory and what a legal clip name
  is, because the retry is handed a name out of a ledger in group-writable
  /share: strict ann-<ext>-<32 hex> with the ext bound to the row's own ext,
  realpath containment, a regular file (never a link), non-zero size, and a
  re-measured duration. It returns the extensionless FULL path — the basename
  would produce a call that answers and plays nothing, and no delivered row.
* the reconciler's horizon now runs from the newest attempt, so a replay in
  flight is not judged undelivered, and its verdict rows carry `retries`.
* api_announce no longer arms the duplicate-suppression window when the
  reachability guard could not judge. That night it was armed by an announcement
  nobody heard, so an identical re-send would have been answered
  {"ok": true, "skipped": "duplicate"} — the v0.85.0 lockout in a new dress.
* announce-retry-skipped (too-old | budget-exhausted | ext-superseded |
  clip-gone) is written exactly once per clip and is NOT terminal: the
  announcement still never arrived, so the reconciler still speaks. Transient
  deferrals are logged and never recorded.
* one ledger read and one clock per tick, shared by both passes; no AMI traffic
  at all unless the pure ledger scan already found a candidate.

No new notifications, no config option (ANNOUNCE_RETRY_MAX_ATTEMPTS=0 in the
environment is a complete kill switch). 63 new tests, all driving the real
callers; the suite goes 802 -> 865. 27 mutants entered, 27 killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven findings from the adversarial review of the announce retry, fixed. The two
that mattered are both about the same thing: a feature that places phone calls in
this house must not be able to place two at once, and the owner must be able to
turn it off.

1. NO TWO REPLAYS TO ONE HANDSET (major). The pass read the endpoint states ONCE
   per tick and then looped over every candidate, so two unresolved announcements
   to one extension fired milliseconds apart off one stale read — neither
   Originate could see the other's channel. Driven through the real caller, three
   queued clips for ext 19 produced THREE Originates in one tick. app.py's own
   busy-guard names the consequence: "a second INVITE cannot auto-answer and would
   RING the handset as call waiting", and api_announce would then report a
   genuinely fresh announcement as skipped-busy behind our stale one. The
   population is not hypothetical — the duplicate-window graft in this same branch
   is what lets identical re-sends through as separate queued rows.

   Fixed in two places, because the cost of being wrong is unsolicited ringing:
   - delivery.retryable_announcements now returns at most ONE live candidate per
     extension, the NEWEST announcement queued to that room; the others are
     retired with the ext-superseded row that already existed for exactly this
     meaning. Ties (record() stamps whole seconds) break on ledger order, which is
     the producer's own order inside that second.
   - _retry_announcements keeps a `fired` set: at most one Originate per extension
     per tick, and the clip held back gives its clean observations BACK rather
     than banking them — they were earned against the state read that this tick's
     own call has just invalidated.

2. THE OFF SWITCH NOW REACHES THE ADD-ON (major). DOCS.md told the owner the
   bounds could be changed in the environment and that ANNOUNCE_RETRY_MAX_ATTEMPTS=0
   switched the feature off. Nothing exported it: an option is INERT until a run
   script exports it, as that script's own comment says, so the only documented way
   to stop the retry needed an image rebuild. Bridged properly, all four edits —
   `announce_retry_attempts` (default 2, range 0-3) in config.yaml options and
   schema, translations/en.yaml, and `export ANNOUNCE_RETRY_MAX_ATTEMPTS="${ARA:-2}"`
   in wakeup-scheduler/run — with a test that pins the option, the range that
   includes 0, the exported NAME against the one delivery.py reads, and that any
   ANNOUNCE_RETRY_* the manual mentions is exported by that script. The two timings
   stay code-only and DOCS.md now says so.

3. The startup bounds line is pinned through main() (minor). Deleting the
   _log_retry_bounds() call left the suite green: its four references all called it
   directly. That line is the only production signal separating "live and idle"
   from "OFF because an import failed", "OFF at 0 attempts" and "INERT at this
   poll".

4. DOCS.md says what an owner will actually notice (minor): the gate is the AUDIO,
   not the reason it was missing, so an announcement nobody answers is retried too
   — up to three rings for one message on the FXS phones, which have no
   auto-answer. The manual had motivated the whole feature with the restart
   incident, which a reader would fairly take as its only trigger.

5. The freshness lock reads QUEUE times, not delivery times (nit). A 60 s clip
   answered before this announcement was even queued writes its audio-delivered
   row a minute after it, which read as "the room has been spoken to since" while
   the truth was the reverse — and retired the one message the room had not heard.

6. Two keys on the returned records that nothing read (`_attempt_ts`,
   `last_attempt_ts`) are gone; a private field with no reader invites the next
   reader to believe it is load-bearing.

7. ActiveChannels stays as it is: fail-closed, and nothing in this repo
   establishes what it holds during a playback. Live stage 2d must record the real
   value and delete the condition if it is empty — the code comment, the risk
   register and the live plan all already say so.

870 passed (865 before; 5 new tests plus assertions on two existing ones), and
the standalone runners for config, translations, run gates, webui, app and the
announce dialplan are green. Mutants: 14 new ones for these fixes and 19 from the
original battery re-run over the two files this touches, all 33 killed, with a
control mutant that must SURVIVE — the first run of the harness reported 14 kills
from a python that had no pytest installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gain

Owner decision (2026-09-16): retry automatically when the audio never played.

- wakeup/scheduler.py gains a third pass, _retry_announcements(), in its own
  try/except: it replays an announcement whose clip has no audio-delivered row,
  bounded at 2 attempts and 150 s from queueing, and only once the target
  endpoint reports itself registered and idle on two reads one poll apart.
- The duplicate-safety join is the clip name, unchanged: a retry re-originates
  the SAME sound, so an audio-delivered row for that clip retires it forever.
  Only the newest announcement queued to a room is live; older ones retire with
  ext-superseded, so two INVITEs can never race to one handset.
- New option `announce_retry_attempts` (default 2, range 0-3; 0 is the off
  switch) — config.yaml options + schema, translations, and the export in the
  wakeup-scheduler run script, with a test pinning all four.
- app.py no longer arms the 300 s duplicate-suppression window when the
  pre-flight guard could not judge the endpoint: that window was being armed by
  an announcement nobody heard.

Found in the 2026-09-15/16 review (third occurrence of the shape).
870 tests; 33 mutants killed plus a control that survived as required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tesseractAZ
tesseractAZ merged commit 3f74613 into main Sep 17, 2026
10 checks passed
@tesseractAZ
tesseractAZ deleted the fix/announce-retry branch September 17, 2026 02:14
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