toga-ai 1.0.481 → 1.0.482
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -6,8 +6,8 @@ project: AI-BDR
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: [akhokhani, tcox]
|
|
9
|
+
updated: 2026-07-30
|
|
10
|
+
owners: [akhokhani, tcox, bala]
|
|
11
11
|
files:
|
|
12
12
|
- ai-bdr/vapi/templates/assistant.template.json
|
|
13
13
|
- ai-bdr/vapi/templates/system-prompt.template.md
|
|
@@ -122,7 +122,7 @@ Returned in `message.analysis.structuredData` on every `end-of-call-report`:
|
|
|
122
122
|
| `contactEmail` | string | Email the prospect confirmed; empty if not collected |
|
|
123
123
|
| `emailMatchedKnown` | boolean | True if confirmed email matches `knownEmail` from variableValues |
|
|
124
124
|
| `meetingTime` | ISO 8601 | Present if `callOutcome=meeting_booked` |
|
|
125
|
-
| `callbackTime` | ISO 8601 | Present if `callOutcome=callback_scheduled
|
|
125
|
+
| `callbackTime` | ISO 8601 *(documented; unconstrained free text in practice)* | Present if `callOutcome=callback_scheduled`. **BUG — the single highest-value schema fix (rescoped 2026-07-30).** The PHP side stores this raw and parses it with PHP's relative-date grammar, which returns null for **22 of 32** realistic phrasings — see below. |
|
|
126
126
|
| `callbackContactPreference` | string | `phone` / `email` / `sms` |
|
|
127
127
|
| `callType` | string | Mirrors the `callType` variable injected on this call |
|
|
128
128
|
| `nextCallOpeningContext` | string | One-sentence coaching note for the next attempt's opener |
|
|
@@ -135,6 +135,25 @@ To change the schema: `python scripts/update_structured_output.py` (dry
|
|
|
135
135
|
run by default; pass `--apply` to write). Adding a field needs a paired
|
|
136
136
|
PHP worker handler change — coordinate before deploying.
|
|
137
137
|
|
|
138
|
+
### `callbackTime` — constraining it here is the recommended durable fix
|
|
139
|
+
|
|
140
|
+
The worker's `convertDateTime` is broken by the **preposition / filler word**, not (as previously
|
|
141
|
+
recorded) by spelled-out numbers: `"10 minutes"` parses but `"in 10 minutes"` does not;
|
|
142
|
+
`"tomorrow 3pm"` parses but `"tomorrow at 3pm"` does not. `in`, `at`, `around` and `sometime` all
|
|
143
|
+
break it, and `"later"` — which appears verbatim in the worker's own sample payload — is null.
|
|
144
|
+
Every failure means a **promised callback is silently never placed**.
|
|
145
|
+
|
|
146
|
+
**ISO 8601 parses cleanly on the PHP side today** (`2026-08-01T15:00:00Z` and the `.000Z` variant).
|
|
147
|
+
So: constrain this field's schema to **strict ISO 8601 with offset** and prompt the assistant to
|
|
148
|
+
resolve relative speech to an absolute timestamp before emitting. That removes the entire class of
|
|
149
|
+
failure without touching PHP — which is why it ranks above the PHP-side hardening.
|
|
150
|
+
|
|
151
|
+
**Unverified (2026-07-30):** the live assistant config could not be inspected from the dev box
|
|
152
|
+
(`C:\WWW\info` is the Next.js funnel — no `vapi/` templates, no `scripts/`, zero matches for
|
|
153
|
+
`callbackTime`). Confirm whether the live schema **already** constrains this field, and which
|
|
154
|
+
server-message types each assistant currently subscribes to, before changing anything. Full
|
|
155
|
+
analysis: [worker2 callback-scheduling.md](../../worker2/features/callback-scheduling.md).
|
|
156
|
+
|
|
138
157
|
## Environment variables (`.env.example`)
|
|
139
158
|
|
|
140
159
|
```
|
|
@@ -181,6 +200,14 @@ ensure UA spoofing is preserved or you'll see 403s with no body.
|
|
|
181
200
|
`callSummary` over a schema fork.
|
|
182
201
|
|
|
183
202
|
## Change history
|
|
203
|
+
- 2026-07-30 — **Rescoped the `callbackTime` bug and promoted the schema-side fix.** The parse
|
|
204
|
+
failure is caused by the preposition/filler word (`in`, `at`, `around`, `sometime`), **not**
|
|
205
|
+
spelled-out numbers — 22/32 realistic phrasings return null, `"tomorrow at 3pm"` among them.
|
|
206
|
+
Since ISO 8601 parses cleanly in PHP, constraining this field to strict ISO 8601 with offset
|
|
207
|
+
(plus prompting the assistant to resolve relative speech before emitting) is the **recommended
|
|
208
|
+
durable fix**. Flagged that the live assistant config could not be inspected, so it is unverified
|
|
209
|
+
whether the schema already constrains the field or which server-message types each assistant
|
|
210
|
+
subscribes to (this matters — the worker has no `message.type` guard). (tcox)
|
|
184
211
|
- 2026-07-21 — Live go-live findings. Recorded that the healthcare assistant runs
|
|
185
212
|
`firstMessageMode = "assistant-waits-for-user"` (Option B) so a silent answerer gets silence
|
|
186
213
|
(confirmed: 8s mutual-silence call, `endedReason=customer-ended-call`) — testers must speak first.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
| [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh, worker2/.platform/hooks/prebuild/_shared/041-write-region.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
|
|
7
7
|
| [All-Client Email Queue Monitor (Monitor/Operations/EmailQueue)](features/all-client-email-queue-monitor.md) | `_Worker_Monitor_Operations::EmailQueue()` is the cross-client health check for the 2.0 outbound email queue (`Logs_<Client>.Email` — see [2.0 Email Send Pipeli | worker2/Worker/Monitor/Operations.php, _underscore/Database.php, _underscore/Query.php, dbchanges2/Logs_Client/2026-05-21 - Email.sql |
|
|
8
8
|
| [Automated PR Merger — Concurrent Force-Push Clobber Race](features/automated-pr-merger-force-push-race.md) | The automated PR merger `_Worker_Team_GitHub::Merge` (`worker2` `Worker/Team/Github.php`) merges approved PRs to `_production` by **force-pushing from a clone t | Worker/Team/Github.php |
|
|
9
|
+
| [Callback Scheduling ("call me back") — worker2 AI-BDR](features/callback-scheduling.md) | The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a Vapi call and the dialer actually placing that second call. | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
|
|
9
10
|
| [ClickUp Connectivity Watchdog](features/clickup-connectivity-watchdog.md) | A cron watchdog that emails when the ClickUp integration looks disconnected during business hours. | worker2/Worker/Clickup/Health.php, worker2/Database/ClickupHealthWatchdog.sql |
|
|
10
11
|
| [ClickUp Design Sprint Automation (Final Design Outcome)](features/clickup-design-sprint-automation.md) | `_Worker_Clickup_Design` is meant to drive the design-sprint workflow in ClickUp via the API, replacing a set of native ClickUp automations. | worker2/Worker/Clickup/Design.php, worker2/Worker/Clickup.php, worker2/Controller/ClickupDesignTest.php, _underscore/Component/Api/Clickup/Clickup.php |
|
|
11
12
|
| [ClickUp GitHub-tab Auto-linking & Ticket-id Branch Naming](features/clickup-github-autolink.md) | How ClickUp surfaces branches/PRs/commits in a ticket's **GitHub tab**, and the branch / PR-title naming convention that triggers it. | |
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Callback Scheduling ("call me back") — worker2 AI-BDR
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-30
|
|
10
|
+
owners: [tcox, bala]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Vapi.php
|
|
13
|
+
- worker2/Worker/Ai/Bdr/Vapi.php
|
|
14
|
+
- worker2/Controller/Index.php
|
|
15
|
+
related:
|
|
16
|
+
- vapi-webhook-handler.md
|
|
17
|
+
- ../../ai-bdr/features/vapi-integration.md
|
|
18
|
+
- ../../ai-bdr/features/call-orchestration.md
|
|
19
|
+
- ../../ai-bdr/workflows/safe-call-loop-testing.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Summary
|
|
23
|
+
|
|
24
|
+
The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a
|
|
25
|
+
Vapi call and the dialer actually placing that second call. It spans the webhook
|
|
26
|
+
(`Worker/Vapi.php` — capture, persist, parse, schedule) and the dialer
|
|
27
|
+
(`Worker/Ai/Bdr/Vapi.php` — eligibility, redial).
|
|
28
|
+
|
|
29
|
+
**It is currently broken in production.** The requested callback is captured and stored
|
|
30
|
+
correctly, but `convertDateTime` cannot parse most of the phrasings prospects actually use, so
|
|
31
|
+
`actionScheduleCallback` aborts one line before the write and **no callback is ever scheduled**.
|
|
32
|
+
This doc records the source-verified root cause, three further defects found on the same path,
|
|
33
|
+
the recommended fix order, and the production queries that confirm it with real data.
|
|
34
|
+
|
|
35
|
+
> **Status / handoff (2026-07-30).** Diagnosis only — **nothing has been fixed and no worker2
|
|
36
|
+
> code was modified.** tcox investigated (read-only, `_production` @ `ef50345`); **bala is
|
|
37
|
+
> picking the topic up next.** Immediate next step: run **Q1** below against `Client_True` to
|
|
38
|
+
> confirm the root cause with production data, then decide whether the durable fix lands in the
|
|
39
|
+
> **Vapi assistant structured-output schema** (recommended) or in the PHP parser.
|
|
40
|
+
|
|
41
|
+
## Key files / entry points
|
|
42
|
+
|
|
43
|
+
| Location | Role |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `Worker/Vapi.php:99, :109–122, :144` | Extracts `callbackTime` from the end-of-call report |
|
|
46
|
+
| `Worker/Vapi.php:161–173, :189–194, :213–215, :256–266` | Persists `c_callbackTime` **raw** + outcome/action onto the attempt |
|
|
47
|
+
| `Worker/Vapi.php:387–431` (`actionScheduleCallback`) | Parses the time and writes `Contacts.dtNextContactRequested` |
|
|
48
|
+
| `Worker/Vapi.php:609–635` (`convertDateTime`) | The parser — **the failure point** |
|
|
49
|
+
| `Worker/Ai/Bdr/Vapi.php:234–241, :314–331` | Dialer eligibility — honours (or blocks) the callback |
|
|
50
|
+
|
|
51
|
+
## How it works (end to end)
|
|
52
|
+
|
|
53
|
+
1. **Capture.** The requested time arrives at
|
|
54
|
+
`message.artifact.structuredOutputs.<schema-uuid>.result.callbackTime`
|
|
55
|
+
(`Worker/Vapi.php:99` → `:109–122` → `:144`) — **not** in `analysis`, which is always empty.
|
|
56
|
+
The published schema documents ISO 8601
|
|
57
|
+
([vapi-integration.md](../../ai-bdr/features/vapi-integration.md)), but in practice the field
|
|
58
|
+
is **unconstrained free text**; the file's own sample payload contains
|
|
59
|
+
`"callbackTime": "later"` (`Worker/Vapi.php:45`).
|
|
60
|
+
2. **Persist (raw).** `updateContactAttempt` stores the string with **no conversion**
|
|
61
|
+
(`:213–215`) alongside `c_callOutcome='CALLBACK_SCHEDULED'` / `c_actionToTake='SCHEDULE_CALLBACK'`
|
|
62
|
+
(map at `:161–173`, applied `:189–194`), via `PUT /contact-attempts/{uuid}` (`:256–266`).
|
|
63
|
+
`c_callbackTime` is `VARCHAR(255)` (dbchanges2 `_modules/Ai/Bdr/2026-03-04 bdrCustomfields.sql:84`),
|
|
64
|
+
so a malformed value **survives persistence intact** and only dies at the act step.
|
|
65
|
+
3. **Act.** `executePostCallActions` dispatches `SCHEDULE_CALLBACK` → `actionScheduleCallback`,
|
|
66
|
+
which resolves the campaign timezone (`:400`), calls `self::convertDateTime($callbackTime, …)`
|
|
67
|
+
(`:401`), and on success writes `Contacts.dtNextContactRequested` (`:414`) and
|
|
68
|
+
`contactCallTypeId = CALLBACK` (`:417`) via `PUT /contacts/{uuid}` (`:420–430`).
|
|
69
|
+
4. **Redial.** The dialer treats a due `dtNextContactRequested` as the privileged path:
|
|
70
|
+
it **bypasses call windows, weekly cadence, call-window exception dates and prior DNC**
|
|
71
|
+
(`Ai/Bdr/Vapi.php:238–303`, `:314–331`). Callbacks also sort first in the per-run queue (`:334`).
|
|
72
|
+
|
|
73
|
+
## Root cause — `convertDateTime` cannot parse the times prospects actually say
|
|
74
|
+
|
|
75
|
+
`actionScheduleCallback` aborts when the parser returns null:
|
|
76
|
+
|
|
77
|
+
- `Worker/Vapi.php:399–406` — logs `[AI-BDR Webhook] Cannot parse callbackTime "…"` and **`return`s
|
|
78
|
+
early**. `dtNextContactRequested` is never written, `contactCallTypeId` is never flipped, and
|
|
79
|
+
nothing downstream ever schedules the call.
|
|
80
|
+
- `Worker/Vapi.php:609–635` — the parser, which leans entirely on PHP's relative-date grammar.
|
|
81
|
+
|
|
82
|
+
**Empirically measured** (parser extracted and executed on PHP 8.1.10): **22 of 32 realistic
|
|
83
|
+
phrasings return null.** Confirmed null: `later`, `in 10 minutes`, `in ten minutes`,
|
|
84
|
+
`tomorrow at 3pm`, `tomorrow at 3`, `Monday at 9am`, `in an hour`, `in 2 days`, `this afternoon`,
|
|
85
|
+
`tomorrow morning`, `end of day`, `around 2pm`, `15 minutes from now`, `next week sometime`.
|
|
86
|
+
|
|
87
|
+
### The correction — it is the PREPOSITION, not spelled-out numbers
|
|
88
|
+
|
|
89
|
+
An earlier note on [vapi-webhook-handler.md](vapi-webhook-handler.md) attributed this to
|
|
90
|
+
spelled-out numbers (`"in ten minutes"`). **That is understated and misleading.** The trigger is
|
|
91
|
+
the **preposition / filler word**:
|
|
92
|
+
|
|
93
|
+
| Parses | Returns null |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `10 minutes` | `in 10 minutes` |
|
|
96
|
+
| `tomorrow 3pm` | `tomorrow at 3pm` |
|
|
97
|
+
| `2026-08-01T15:00:00Z` (and the `.000Z` variant) | `around 2pm`, `next week sometime` |
|
|
98
|
+
|
|
99
|
+
The words **`in`, `at`, `around`, `sometime`** break PHP's relative-date grammar. Note that
|
|
100
|
+
`"tomorrow at 3pm"` — the exact phrasing the code comment at `:399` claims works — **fails**.
|
|
101
|
+
Spelled-out numbers are a real but secondary contributor. **ISO 8601 parses cleanly**, which is
|
|
102
|
+
why the durable fix belongs in the Vapi schema.
|
|
103
|
+
|
|
104
|
+
### Ruled out — do not re-investigate the downstream wiring
|
|
105
|
+
|
|
106
|
+
The "recorded as data but never scheduled as an action" hypothesis is **FALSE**; it fails one
|
|
107
|
+
line *before* the write.
|
|
108
|
+
|
|
109
|
+
- `actionScheduleCallback` **does** write `dtNextContactRequested` (`:414`) and
|
|
110
|
+
`contactCallTypeId` (`:417`) via `PUT /contacts/{uuid}` (`:420–430`). An exhaustive grep confirms
|
|
111
|
+
this is the **only** writer of `dtNextContactRequested` outside the dialer's own IMMEDIATE /
|
|
112
|
+
403-retry stamps (`Ai/Bdr/Vapi.php:421`, `:477`).
|
|
113
|
+
- The dialer **honours** it, bypassing call windows, cadence, exception dates and prior DNC.
|
|
114
|
+
- **`CALLBACK_SCHEDULED` is NOT a terminal outcome.** The exclusion at
|
|
115
|
+
`Ai/Bdr/Vapi.php:317–330` lists only `c_dncRequested=1`, `c_actionToTake='DO_NOT_CALL'`, and
|
|
116
|
+
`c_callOutcome IN ('MEETING_BOOKED','DNC_REQUESTED')` — and the whole block is short-circuited
|
|
117
|
+
when `dtNextContactRequested IS NOT NULL` (`:316`).
|
|
118
|
+
|
|
119
|
+
## Defect 2 (COMPLIANCE) — times that *do* parse are frequently wrong, sometimes in the past
|
|
120
|
+
|
|
121
|
+
There is **no future/sanity check** between the parse and the write (`Worker/Vapi.php:401–414`),
|
|
122
|
+
and dialer eligibility is simply `dtNextContactRequested <= NOW()` (`Ai/Bdr/Vapi.php:241`).
|
|
123
|
+
|
|
124
|
+
Measured at **2026-07-30 09:29 CT**, campaign timezone `America/New_York`:
|
|
125
|
+
|
|
126
|
+
| Spoken | Resolved to | Effect |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `Thursday` | 2026-07-29 23:00 | **In the past** — redials on the very next cron minute |
|
|
129
|
+
| `tomorrow` | 2026-07-30 23:00 | An **11 PM call** (midnight ET rendered as 23:00 CT) |
|
|
130
|
+
| `3:00 PM tomorrow` | 2026-07-30 23:00 | Time silently dropped — **wrong day AND wrong time** |
|
|
131
|
+
| `2pm` | earlier today | In the past whenever the call ended after 2pm |
|
|
132
|
+
|
|
133
|
+
Because the callback path **deliberately bypasses `CampaignCallWindows`**, nothing downstream
|
|
134
|
+
catches an 11 PM dial or a redial 60 seconds later. **Treat this as TCPA/compliance exposure, not
|
|
135
|
+
just a UX bug.**
|
|
136
|
+
|
|
137
|
+
## Defect 3 — `maxAttemptsPerContact` is NOT bypassed for callbacks
|
|
138
|
+
|
|
139
|
+
The attempt-cap predicate sits **outside** the `dtNextContactRequested` OR-branch
|
|
140
|
+
(`Ai/Bdr/Vapi.php:234–235`; the branch begins at `:238`). Default cap is **3**
|
|
141
|
+
(dbchanges2 `Client/2026-06-03- BLANK_CLIENT_DATABASE.sql:1852`).
|
|
142
|
+
|
|
143
|
+
So a prospect who asks for a callback **on their 3rd attempt** gets `dtNextContactRequested` set
|
|
144
|
+
correctly and is then **permanently ineligible**. Callbacks bypass call windows, cadence,
|
|
145
|
+
exception dates and prior DNC — but **not** the attempt cap. This is a silent policy conflict:
|
|
146
|
+
the cap overrides an explicit customer request. Decide the policy explicitly (exempt
|
|
147
|
+
callback-requested contacts, or grant +1).
|
|
148
|
+
|
|
149
|
+
## Defect 4 — the failure is silent all the way out to Vapi
|
|
150
|
+
|
|
151
|
+
`actionScheduleCallback`'s write (`Worker/Vapi.php:420–430`) is the **only** write in the file
|
|
152
|
+
with **no try/catch** (compare `:255–284` and `:363–367`), and it is called with
|
|
153
|
+
`$throwExceptionOnApiError = true` — `_underscore` `Component/Api/Toga/Toga.php:167` / `:172`
|
|
154
|
+
throw. The controller then **swallows the throw and returns HTTP 200**
|
|
155
|
+
(`worker2/Controller/Index.php:511–526`), so **Vapi never retries** and the callback is lost with
|
|
156
|
+
no external signal. The only trace is `Core.WorkerJobs.failureReason`.
|
|
157
|
+
|
|
158
|
+
## Smaller defects found on the same path
|
|
159
|
+
|
|
160
|
+
- **An empty `Campaigns.timezone` silently kills the callback.** `Worker/Vapi.php:400` uses `??`,
|
|
161
|
+
which does **not** catch `''`. `Campaigns.timezone` is `varchar(64) NOT NULL` with **no default**
|
|
162
|
+
(blank DB `:1851`), so `''` is storable, `new DateTimeZone('')` throws, and the parse returns
|
|
163
|
+
null. Verified that `timezone='Central'` also fails — **only IANA identifiers work.**
|
|
164
|
+
- **No audit of what was actually scheduled.** Only the raw string is stored; the resolved
|
|
165
|
+
datetime is never persisted, so a failed parse cannot be deterministically re-run.
|
|
166
|
+
- **The attempt's call type gets clobbered.** `Worker/Vapi.php:178` writes the **contact's**
|
|
167
|
+
`contactCallTypeId` onto the **attempt**, overwriting what the dialer stamped at
|
|
168
|
+
`Ai/Bdr/Vapi.php:701` (and nulls it when the contact's is NULL).
|
|
169
|
+
- **`Contacts.contactCallTypeId` is never reset to SCHEDULED** after a callback completes, so
|
|
170
|
+
every subsequent call keeps taking the callback prompt branch (`Ai/Bdr/Vapi.php:371–372`).
|
|
171
|
+
- **`Campaigns.dateEnd` kills late callbacks.** `validateCampaignReady:784–786` throws once today
|
|
172
|
+
> `dateEnd`; it is caught at `ProcessCampaignCalls:144–146` and returned as `success:false`. A
|
|
173
|
+
callback promised for after the campaign end date **silently never fires**.
|
|
174
|
+
- **Per-run cap of 10** (`MAX_CALLS_PER_MINUTE` at `:8`, applied `:339–340`). Callbacks sort first
|
|
175
|
+
(`:334`) so this rarely bites, but >10 simultaneously-due callbacks queue.
|
|
176
|
+
|
|
177
|
+
## Recommended fix direction (decided 2026-07-30 — priority order)
|
|
178
|
+
|
|
179
|
+
1. **DURABLE FIX — Vapi assistant config side.** Constrain `callbackTime` in the structured-output
|
|
180
|
+
schema to **strict ISO 8601 with offset**, and prompt the assistant to resolve relative speech
|
|
181
|
+
to an absolute timestamp before emitting. ISO parses cleanly today, so this removes the whole
|
|
182
|
+
class of failure. See [vapi-integration.md](../../ai-bdr/features/vapi-integration.md).
|
|
183
|
+
2. **`Worker/Vapi.php:71–79` and `:135`** — add
|
|
184
|
+
`if (($message->type ?? '') !== 'end-of-call-report') return 'ok';` and stop force-stamping
|
|
185
|
+
`dtEnded` from `NOW()` (set it only from a real `endedAt`). Independent of the callback bug and
|
|
186
|
+
arguably **more urgent** — see the concurrency gotcha in
|
|
187
|
+
[vapi-webhook-handler.md](vapi-webhook-handler.md).
|
|
188
|
+
3. **`:609–635` and `:403–406`** — defensive normalization (strip leading `in `/`at `/`around `/
|
|
189
|
+
`sometime `, map spelled-out numbers) and, on failure, **schedule a flagged sane default rather
|
|
190
|
+
than `return`** — never silently drop a promised callback.
|
|
191
|
+
4. **`:401–414`** — clamp the parsed time: if `<= now`, push to the next valid slot; snap into the
|
|
192
|
+
campaign's call windows; reject beyond ~90 days.
|
|
193
|
+
5. **`Ai/Bdr/Vapi.php:234–235`** — decide the attempt-cap policy explicitly.
|
|
194
|
+
6. **`:420–430`** — add a try/catch matching the file's own pattern and make failures **loud**
|
|
195
|
+
(Sentry, not just `error_log`).
|
|
196
|
+
7. **`:400`** — use `?:` instead of `??`, validate against `DateTimeZone::listIdentifiers()`, and
|
|
197
|
+
add a DB default for `Campaigns.timezone`.
|
|
198
|
+
8. **dbchanges2 `_modules/Ai/Bdr/`** — add a resolved `c_callbackTimeResolved DATETIME` column for
|
|
199
|
+
auditability and re-runnability.
|
|
200
|
+
|
|
201
|
+
## Diagnostic queries (run against `Client_True`)
|
|
202
|
+
|
|
203
|
+
**Q1 — decisive.** Join attempts to contacts for callbacks in the last 30 days:
|
|
204
|
+
|
|
205
|
+
```sql
|
|
206
|
+
SELECT ca.attemptId, ca.contactId, ca.campaignId, ca.dtStarted, ca.dtEnded,
|
|
207
|
+
ca.c_callOutcome, ca.c_actionToTake, ca.c_callbackTime,
|
|
208
|
+
c.dtNextContactRequested, c.contactCallTypeId, c.isOkayToCall
|
|
209
|
+
FROM ContactAttempts ca
|
|
210
|
+
JOIN Contacts c ON c.contactId = ca.contactId
|
|
211
|
+
WHERE ca.c_callOutcome = 'CALLBACK_SCHEDULED'
|
|
212
|
+
AND ca.dtEnded >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
|
213
|
+
ORDER BY ca.dtEnded DESC;
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Interpretation:
|
|
217
|
+
- `c_callbackTime` non-empty **AND** `dtNextContactRequested IS NULL` → **the parse-failure root
|
|
218
|
+
cause** (or the silent throw of Defect 4).
|
|
219
|
+
- `c_callbackTime` empty/NULL → the model never emitted the field.
|
|
220
|
+
- `dtNextContactRequested < ca.dtEnded` → **scheduled in the past** (Defect 2).
|
|
221
|
+
- Set correctly → scheduling worked; move to Q3 (attempt cap).
|
|
222
|
+
|
|
223
|
+
**Q2 — what the parser must actually handle:**
|
|
224
|
+
|
|
225
|
+
```sql
|
|
226
|
+
SELECT c_callbackTime, COUNT(*) AS n
|
|
227
|
+
FROM ContactAttempts
|
|
228
|
+
WHERE c_callbackTime IS NOT NULL AND c_callbackTime <> ''
|
|
229
|
+
GROUP BY c_callbackTime
|
|
230
|
+
ORDER BY n DESC;
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Q3** — contacts with a due `dtNextContactRequested` grouped against `maxAttemptsPerContact`, to
|
|
234
|
+
flag **BLOCKED BY ATTEMPT CAP**.
|
|
235
|
+
**Q4** — open attempts (`dtStarted IS NOT NULL AND dtEnded IS NULL`) with hours-open; these wedge a
|
|
236
|
+
contact out of **all** future dialing.
|
|
237
|
+
**Q5** — active-campaign config sanity: flag blank `timezone` and expired `dateEnd`.
|
|
238
|
+
|
|
239
|
+
Also:
|
|
240
|
+
- Grep the worker log / Sentry for **`Cannot parse callbackTime`** — **every hit is a lost callback.**
|
|
241
|
+
- In `Core`, surface the silent throw:
|
|
242
|
+
```sql
|
|
243
|
+
SELECT id, dtCompleted, failureReason FROM WorkerJobs
|
|
244
|
+
WHERE isSuccess = 0 AND failureReason LIKE '%Vapi%'
|
|
245
|
+
ORDER BY dtCompleted DESC LIMIT 50;
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Verification gaps (do not treat these as confirmed)
|
|
249
|
+
|
|
250
|
+
- **No production DB access from a dev laptop** (the client DB needs VPN) — every production claim
|
|
251
|
+
above is a **code/schema inference pending Q1–Q5**.
|
|
252
|
+
- **The live Vapi assistant config could not be inspected.** `C:\WWW\info` is the Next.js funnel:
|
|
253
|
+
no `vapi/` templates, no `scripts/`, zero matches for `callbackTime`. So it is **unconfirmed**
|
|
254
|
+
whether the live structured-output schema already constrains the field, and which server-message
|
|
255
|
+
types each assistant currently subscribes to.
|
|
256
|
+
- **Parser results verified on PHP 8.1.10 only** (an `upgrade-to-php8.5` branch exists; PHP's
|
|
257
|
+
relative-date grammar has been stable across 7.x–8.x, so results are not expected to differ).
|
|
258
|
+
- **Vapi's actual event ordering** — whether a `status-update` can land *after* the
|
|
259
|
+
`end-of-call-report` — could not be observed.
|
|
260
|
+
- **api2's PUT semantics for an explicit `null` were not confirmed from source.** If api2 ignores
|
|
261
|
+
an explicit null, the clearing hazard in the webhook doc's concurrency gotcha disappears. Worth a
|
|
262
|
+
one-line check.
|
|
263
|
+
|
|
264
|
+
## Change history
|
|
265
|
+
- 2026-07-30 — Initial doc. Read-only root-cause investigation of the production "call me back
|
|
266
|
+
never happens" bug (worker2 `_production` @ `ef50345`, `_underscore` `_production` @ `5047d135`).
|
|
267
|
+
Root cause: `convertDateTime` returns null for 22/32 realistic phrasings, so
|
|
268
|
+
`actionScheduleCallback` aborts before writing `dtNextContactRequested`. **Corrects** the earlier
|
|
269
|
+
"spelled-out numbers" attribution — the real trigger is the preposition/filler word (`in`, `at`,
|
|
270
|
+
`around`, `sometime`); `"tomorrow at 3pm"` (the phrasing the code comment claims works) fails,
|
|
271
|
+
ISO 8601 parses cleanly. Ruled out the downstream wiring (the write, the dialer bypass, and
|
|
272
|
+
`CALLBACK_SCHEDULED` not being terminal are all fine). Also recorded: parsed times are often
|
|
273
|
+
wrong/in the past with call windows bypassed (compliance exposure); `maxAttemptsPerContact` is
|
|
274
|
+
**not** bypassed for callbacks; the failure is silent out to Vapi (HTTP 200, no retry); plus the
|
|
275
|
+
fix priority order and the Q1–Q5 production queries. Diagnosis only — no code changed; handed off
|
|
276
|
+
to bala. (tcox)
|
|
277
|
+
</content>
|
|
278
|
+
</invoke>
|
|
@@ -6,13 +6,14 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: [snaredla, tcox]
|
|
9
|
+
updated: 2026-07-30
|
|
10
|
+
owners: [snaredla, tcox, bala]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Vapi.php
|
|
13
13
|
- worker2/Worker/Ai/Bdr/Vapi.php
|
|
14
14
|
- worker2/Controller/Index.php
|
|
15
15
|
related:
|
|
16
|
+
- callback-scheduling.md
|
|
16
17
|
- ../../ai-bdr/features/call-orchestration.md
|
|
17
18
|
- ../../ai-bdr/features/vapi-integration.md
|
|
18
19
|
- ../../ai-bdr/features/live-call-status.md
|
|
@@ -114,6 +115,30 @@ other types before they reach `processVapiEndOfCallPayload()`.
|
|
|
114
115
|
- **Symptom:** `Warning: Undefined property: stdClass::$contactAttemptUuid` (promoted to an
|
|
115
116
|
`ErrorException` by the framework error handler, captured by Sentry).
|
|
116
117
|
|
|
118
|
+
### The guard is STILL ABSENT on `_production` — and it is a concurrency hazard, not a data-quality one
|
|
119
|
+
|
|
120
|
+
Confirmed 2026-07-30 on `_production` @ `ef50345`: the string `end-of-call-report` appears **only
|
|
121
|
+
in the sample-payload comment at `Worker/Vapi.php:14`**, and a grep for `message->type` across all
|
|
122
|
+
of worker2 returns **zero hits**. `Webhook()` calls `processVapiEndOfCallPayload()`
|
|
123
|
+
**unconditionally** (`:71–79`). Verified consequences:
|
|
124
|
+
|
|
125
|
+
- **`dtEnded` is force-stamped `NOW()` on EVERY event** (`?: date(…)` at `:135`). So a **mid-call
|
|
126
|
+
`status-update` closes the attempt while the call is still live**, which releases the
|
|
127
|
+
in-progress lock at `Ai/Bdr/Vapi.php:305–313` and lets the dialer place **a second concurrent
|
|
128
|
+
call to the same person, mid-conversation.** `status-update` is live-subscribed on the
|
|
129
|
+
healthcare assistant ([vapi-integration.md](../../ai-bdr/features/vapi-integration.md)).
|
|
130
|
+
- It also **NULLs `Contacts.dtNextContactRequested` on every event** (`:268–281`).
|
|
131
|
+
- **Ruled out:** it does *not* blank `c_callbackTime` / `c_callOutcome` — the `!== ''` guards at
|
|
132
|
+
`:189–215` prevent that.
|
|
133
|
+
- **Replay hazard.** Within a single request the order is clear-then-set, so one event is
|
|
134
|
+
self-consistent. But a late or duplicate event arriving **after** the report re-runs
|
|
135
|
+
`actionScheduleCallback` against the **raw stored string re-anchored to a new "now"** — so
|
|
136
|
+
relative times drift forward on each replay, and unparseable ones clear
|
|
137
|
+
`dtNextContactRequested` **without restoring it**, leaving it permanently NULL.
|
|
138
|
+
|
|
139
|
+
Fix: `if (($message->type ?? '') !== 'end-of-call-report') return 'ok';` at `:71–79`, and stop
|
|
140
|
+
force-stamping `dtEnded` from `NOW()` — set it only from a real `endedAt`.
|
|
141
|
+
|
|
117
142
|
## Inbound / test calls vs worker-placed outbound calls
|
|
118
143
|
|
|
119
144
|
The metadata shape depends on **who placed the call**, not the event type:
|
|
@@ -194,6 +219,13 @@ above is easy to over-read:
|
|
|
194
219
|
The exclusion (L314–331: `c_dncRequested`, `DO_NOT_CALL`, `MEETING_BOOKED` / `DNC_REQUESTED`
|
|
195
220
|
outcomes) applies only on the non-requested path; a customer who actively re-engages
|
|
196
221
|
(`dtNextContactRequested`) is dialed regardless of a prior terminal outcome.
|
|
222
|
+
- **The attempt cap is the ONE gate the requested path does NOT bypass** (source-verified
|
|
223
|
+
2026-07-30). The `attemptCount < Campaigns.maxAttemptsPerContact` predicate sits **outside** the
|
|
224
|
+
`dtNextContactRequested` OR-branch (`Ai/Bdr/Vapi.php:234–235`; the branch starts at `:238`), and
|
|
225
|
+
the default cap is **3**. So a prospect who asks for a callback on their **3rd** attempt gets
|
|
226
|
+
`dtNextContactRequested` set correctly and is then **permanently ineligible** — the cap silently
|
|
227
|
+
overrides an explicit customer request. See
|
|
228
|
+
[callback-scheduling.md](callback-scheduling.md).
|
|
197
229
|
- **The normal (non-requested) path requires a `CampaignCallWindows` row to EXIST for the current
|
|
198
230
|
day/time.** A campaign with **no** call-window rows can therefore **only ever dial contacts with a
|
|
199
231
|
pending `dtNextContactRequested`** — this is the **safest launch posture for a web-funnel-only
|
|
@@ -239,19 +271,30 @@ character — a 37-char value) **passes** the readiness gate and the campaign is
|
|
|
239
271
|
Vapi `POST /call/phone` **400s** at call time. Validate the assistant identifier's shape before
|
|
240
272
|
assigning an assistant to any campaign.
|
|
241
273
|
|
|
242
|
-
### Gotcha (HIGH — call quality) —
|
|
274
|
+
### Gotcha (HIGH — call quality) — most spoken callback times are silently dropped
|
|
275
|
+
|
|
276
|
+
> **CORRECTED 2026-07-30.** This gotcha previously blamed **spelled-out numbers**
|
|
277
|
+
> (`"in ten minutes"`). That attribution was **understated and misleading** — the real trigger is
|
|
278
|
+
> the **preposition / filler word**, and the failure rate is far higher than the old wording
|
|
279
|
+
> implied (**22 of 32** realistic phrasings return null, measured on PHP 8.1.10).
|
|
280
|
+
|
|
281
|
+
`actionScheduleCallback` (`Worker/Vapi.php` ~L387–431) parses `structured.callbackTime` through
|
|
282
|
+
`convertDateTime` (~L609–635), which leans entirely on PHP's relative-date grammar. When it
|
|
283
|
+
returns null the method `error_log`s `"Cannot parse callbackTime"` and **`return`s early**
|
|
284
|
+
(`:399–406`) — `dtNextContactRequested` is never set and `callType` is never flipped to
|
|
285
|
+
`CALLBACK`, so the promised callback **never happens** and nothing surfaces the failure.
|
|
286
|
+
|
|
287
|
+
The words **`in`, `at`, `around`, `sometime`** break the parse: `"10 minutes"` works but
|
|
288
|
+
`"in 10 minutes"` does not; `"tomorrow 3pm"` works but **`"tomorrow at 3pm"` — the exact example
|
|
289
|
+
the code comment at `:399` claims works — does not.** Also null: `later`, `Monday at 9am`,
|
|
290
|
+
`in an hour`, `in 2 days`, `this afternoon`, `tomorrow morning`, `end of day`, `15 minutes from now`.
|
|
291
|
+
**ISO 8601 parses cleanly** (`2026-08-01T15:00:00Z` and the `.000Z` variant), which is why the
|
|
292
|
+
durable fix belongs in the Vapi structured-output schema.
|
|
243
293
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
number as a word), `convertDateTime` returns **null**, `actionScheduleCallback` `error_log`s
|
|
249
|
-
`"Cannot parse callbackTime"` and **aborts** — `dtNextContactRequested` is never set and `callType`
|
|
250
|
-
is never flipped to `CALLBACK`, so the promised callback **never happens** and nothing surfaces the
|
|
251
|
-
failure. Reproduced live 2026-07-21 (attempt 183, contact 248). Two recommended fixes (do both):
|
|
252
|
-
(a) constrain the Vapi structured-output `callbackTime` schema to ISO 8601 / numeric-relative so the
|
|
253
|
-
model can't spell the number out (see `../../ai-bdr/features/vapi-integration.md`); (b) defensively
|
|
254
|
-
normalize spelled-out numbers (`one`..`sixty`) in `convertDateTime` before parsing.
|
|
294
|
+
**Full root-cause analysis, three further defects on the same path (wrong/past times with call
|
|
295
|
+
windows bypassed; `maxAttemptsPerContact` not bypassed for callbacks; the silent HTTP-200
|
|
296
|
+
failure), the fix priority order, and the production diagnostic queries live in
|
|
297
|
+
[callback-scheduling.md](callback-scheduling.md).**
|
|
255
298
|
|
|
256
299
|
### Gotcha (SECURITY) — the Vapi API bearer token is hardcoded in source
|
|
257
300
|
|
|
@@ -287,7 +330,13 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
|
|
|
287
330
|
`c_vapiCallIdentifier`.
|
|
288
331
|
- **No message-type guard = errors.** Always filter to `end-of-call-report` first.
|
|
289
332
|
- **Persistence is via the Toga REST API**, not a direct model `save()` — failures are
|
|
290
|
-
caught and logged, not re-thrown.
|
|
333
|
+
caught and logged, not re-thrown. **One exception:** `actionScheduleCallback`'s
|
|
334
|
+
`PUT /contacts/{uuid}` (`:420–430`) is the only write in the file **without** a try/catch
|
|
335
|
+
(compare `:255–284` and `:363–367`) and is called with `$throwExceptionOnApiError = true`
|
|
336
|
+
(`_underscore Component/Api/Toga/Toga.php:167`, `:172` throw). Because
|
|
337
|
+
`Controller/Index.php:511–526` swallows the throw and still returns **HTTP 200**, Vapi never
|
|
338
|
+
retries and the callback is lost with **no external signal** — the only trace is
|
|
339
|
+
`Core.WorkerJobs.failureReason`.
|
|
291
340
|
- **Timestamps are stored in Central Time**; Cal.com booking converts `c_meetingTime` back to
|
|
292
341
|
UTC.
|
|
293
342
|
|
|
@@ -296,6 +345,20 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
|
|
|
296
345
|
- [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
|
|
297
346
|
|
|
298
347
|
## Change history
|
|
348
|
+
- 2026-07-30 — **Corrected** the HIGH callback gotcha: the parse failure is driven by the
|
|
349
|
+
**preposition/filler word** (`in`, `at`, `around`, `sometime`), **not** spelled-out numbers —
|
|
350
|
+
`"tomorrow at 3pm"` (the phrasing the code comment claims works) fails, and 22/32 realistic
|
|
351
|
+
phrasings return null. Split the full root cause + three further defects + fix order + production
|
|
352
|
+
queries into the new [callback-scheduling.md](callback-scheduling.md). Also, from the same
|
|
353
|
+
read-only `_production` @ `ef50345` investigation: the **`message.type` guard is still absent**
|
|
354
|
+
(zero `message->type` hits repo-wide) and is a **concurrency hazard** — `dtEnded` is force-stamped
|
|
355
|
+
`NOW()` on every event, so a mid-call `status-update` closes the attempt, releases the
|
|
356
|
+
in-progress lock and lets the dialer place a second concurrent call to the same person; plus a
|
|
357
|
+
replay hazard that can leave `dtNextContactRequested` permanently NULL. Recorded that the attempt
|
|
358
|
+
cap is the one gate the requested path does **not** bypass, and that
|
|
359
|
+
`actionScheduleCallback`'s write is the file's only un-try/catch'd write while the controller
|
|
360
|
+
still returns HTTP 200 (silent loss, no Vapi retry). Diagnosis only — no code changed; handed off
|
|
361
|
+
to bala. (tcox)
|
|
299
362
|
- 2026-07-23 — Broadened the SECURITY credential-rotation gotcha: the AI-BDR rotation ticket now
|
|
300
363
|
also covers the **HubSpot access token** and **Toga API client secret** committed to git in the
|
|
301
364
|
old `info` repo's tracked `.env.*` files (treat as compromised), alongside the hardcoded Vapi
|
package/package.json
CHANGED