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-21
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`; backend converts a spoken time to ISO. **BUG (2026-07-21): the model frequently emits natural language with spelled-out numbers (e.g. `"in ten minutes"`) which the PHP parser cannot handle — the callback is then silently dropped. See the convertDateTime gotcha in [worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md). Fix candidate on this side: constrain this field's schema to ISO 8601 or a numeric-relative format so the model never spells the number out.** |
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-29
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) — a spoken callback time with a SPELLED-OUT number is silently dropped
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
- `actionScheduleCallback` (`Worker/Vapi.php` ~L387–431) parses the assistant's
245
- `structured.callbackTime` through `convertDateTime` (~L609–635), which relies on PHP's date
246
- parser. PHP handles relative phrasings like `"10 minutes"` and `"tomorrow at 3pm"` but **NOT
247
- spelled-out numbers**. When the model emits `c_callbackTime = "in ten minutes"` (natural language,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.481",
3
+ "version": "1.0.482",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",