Skip to content

Release v0.105.2 — the pre-send handset check reads the handset - #104

Merged
tesseractAZ merged 1 commit into
mainfrom
fix/ami-split-response-frame
Sep 22, 2026
Merged

tesseractAZ merged 1 commit into
mainfrom
fix/ami-split-response-frame

Conversation

@tesseractAZ

Copy link
Copy Markdown
Owner

What was wrong

Before it places an announcement, the web UI reads the handset's DEVICE_STATE over AMI and skips the call if the phone is busy or has no contact. That read returned "" on 28 of 29 announcements, so the guard judged nothing and every announcement was recorded announce-guard-unjudged.

Captured on the production PBX: Asterisk writes a Getvar reply in two writes about 10 µs apart. astman_start_ack() sends Response: Success / ActionID: with no terminator, then Variable: / Value: and the blank line follow. parse_ami_blocks() parses the unterminated tail as a block, so _ami_command(single_response=True) stopped on the header, logged off and dropped the Value.

Changes

  • ami.terminated_blocks(): read-loop stop tests act only on blocks whose \r\n\r\n has arrived. It is used by the single-response stop, actions_responded (the batched codec Getvars) and actions_complete. The final parse still uses parse_ami_blocks, so a read that times out keeps what it received. Every other single-response reply (Originate, Hangup, Redirect, PJSIPNotify, PJSIPRegister, errors) is written whole, so its stop fires on the same recv as before.
  • Refusals are replayed: with the guard working, skipped-busy and unreachable become real. Both rows now carry the clip, and delivery.ANNOUNCE_GUARD_REFUSED makes them retry candidates under the v0.105.0 rules (idle twice, two attempts, ≤150 s, newest per room). Otherwise the fix would have turned "originated, failed, replayed" into "refused, never replayed", which is the post-restart cordless case v0.105.0 exists for.
  • Reconciler: a refused clip is judged once it has been replayed, since the replay handed it to Asterisk. A refusal that was never replayed gets no verdict.
  • HTTP responses are unchanged. The HA media player logs non-200 responses and never raises them, so producers do not re-send.

Verification

  • 885 tests pass. New tests use the live two-write framing.
  • An 11-mutant battery reverts each change separately. All 11 are killed, each by its own targeted test.
  • An adversarial review checked the stop predicates against the Asterisk 20 manager.c source and found no framing defect. Its retry-contract findings were addressed: the reconciler verdict gap is fixed, and the producer re-send path was checked and does not exist.

🤖 Generated with Claude Code

Asterisk writes an AMI Getvar reply in two pieces: astman_start_ack()
sends Response/ActionID unterminated, then Variable/Value and the blank
line follow in a second write. parse_ami_blocks() parses the
unterminated tail, so the single-response read loop stopped on the
header, logged off and dropped the Value. DEVICE_STATE read "" on 28 of
29 announcements and the busy/unreachable guard judged nothing.

- ami.terminated_blocks(): stop tests act only on blocks whose
  terminator has arrived (_ami_command single-response stop,
  actions_responded, actions_complete). Final parse unchanged.
- The two guard refusals (skipped-busy, unreachable) now carry the clip
  and join the retry population (delivery.ANNOUNCE_GUARD_REFUSED), so
  a refused announcement is replayed under the v0.105.0 rules instead
  of never.
- The reconciler judges a refused clip once it has been replayed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tesseractAZ
tesseractAZ merged commit ba6ccf4 into main Sep 22, 2026
10 checks passed
@tesseractAZ
tesseractAZ deleted the fix/ami-split-response-frame branch September 22, 2026 15:23
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