Two bugs between my AI and its first phone call
This morning my AI assistant was cleared to contact fourteen strangers on my behalf. Scrap dealers, aluminium stockists and a foundry, spread across industrial areas in Sharjah, Ajman and Dubai, plus two classifieds sellers. It’s a metal-casting project where the assistant does the research and negotiation and I do the driving and the paying. It had spent days building the shortlist: corroborating phone numbers, drafting messages, staging everything as JSON files in a git repo. Its operating envelope: negotiate and close autonomously up to AED 300 a deal, and every message opens by identifying itself as my assistant, enforced in adapter code.
“Phone call” is generous. These are WhatsApp and Dubizzle messages. But it was the first time the system would speak to a human who doesn’t work for it, so it felt like one.
The plumbing is deliberately dumb. An outbox directory of JSON files. A bridge process attaches to my own logged-in WhatsApp Web session, whatsapp-web.js driving the page over the DevTools protocol, and it takes files[0], sends it, moves it to sent/. Everything mirrors into my own apps, so I see what it said the moment it said it.
I said go. Dubizzle: both sent. WhatsApp: nothing.
Bug 1: the orphan
Attaching to profile (logged-in WhatsApp Web).
Then silence. client.initialize() never resolves and never rejects. No stack trace, no timeout, nothing.
First rule of “hangs with no error”: find out who else is holding the resource.
$ ps -eo pid,etime,command | grep bridge
84213 4:32:11 node bridge/index.js
A bridge from last night’s dry run, started 01:11, still alive four and a half hours later. whatsapp-web.js works by injecting hooks into the WhatsApp Web page. Two clients injecting into one session fight over page state, and neither reaches READY. The orphan wasn’t doing anything useful. It was just attached, which was enough.
Kill the orphan, then fix it forever: a single-instance lockfile with stale-PID detection.
const old = parseInt(fs.readFileSync(LOCK, 'utf8'), 10);
if (old) { process.kill(old, 0); /* still alive → refuse to start */ }
// ESRCH → stale lock, proceed
process.kill(pid, 0) sends no signal; it just asks whether the process exists. Eleven lines. The kind of code that has existed since the seventies and still has to be written.
Bug 2: parked one step before READY
Orphan dead, fresh boot. Still hangs. Same symptom, still no error, and now nothing else was attached.
This is where it got interesting, and where I’d like to claim credit. The assistant did it. The bridge already speaks the DevTools protocol, so it probed the live page directly:
Socket.state 'CONNECTED'
Socket.hasSynced true
window.WWebJS present (46 keys)
onAppStateHasSyncedEvent function, exposed and callable
Page fully synced. Library utilities fully injected. Event listeners attached. And READY, the one event the entire send pipeline waits on, never emitted. The library’s sync handler had run right up to the final step and stopped, like a relay runner pulling up a metre before the line.
Known upstream bug: whatsapp-web.js against WhatsApp Web 2.3000.x, READY never fires (wwebjs #127084). And the reason nothing surfaces: the handler runs as an exposeFunction callback, and puppeteer swallows rejections from those. The failure is silent by construction.
Can’t fix upstream on go-day. What you can do is exploit the library’s own guard: the handler sets injected = true after the heavy work, so re-firing it skips everything already done and falls through to emit(READY). Hence a watchdog. Sixty seconds after boot, if READY hasn’t fired, probe the page, and only if it’s genuinely synced and injected, re-fire the handler from inside the page:
ready-watchdog: page synced+injected but READY missing — re-firing handler (wwebjs#127084 workaround)
Nudge, READY, first message out seconds later. 06:16.
Bug 3, live: the poison pill
The batch ran, paced 40–80 seconds between sends. Seven deliveries in. Then:
Send failed P-008.json: No LID for user
“No LID for user” is WhatsApp for “this number is not on WhatsApp”. The send loop takes files[0], and a failed send left the file in place, so the bridge retried the same dead number every 20 seconds, forever, with the rest of the queue wedged behind it.
Retrying wasn’t wrong in itself. The same morning, a Dubizzle send failed on a transient chat “Connection Issue” and genuinely succeeded on retry 20 seconds later. Retrying forever was wrong. Fix: three strikes, then the file moves to failed/ and the queue moves on.
Send failed P-008.json (attempt 3/3): No LID for user
P-008.json moved to outbox/failed/ — queue unblocked.
The assistant then dug up an alternate mobile for the same dealer, staged it as P-008b, and sent it at 06:40. All fourteen counterparties reached before seven in the morning.
What actually failed
Score the morning: zero failures of intelligence, three failures of plumbing.
- An orphaned process holding a shared resource.
- An upstream library drifting against a moving target it doesn’t control.
- A poison-pill item wedging a FIFO queue.
Every one of these could have hit a cron job in 2005. The fixes, a lockfile, a watchdog and a bounded retry, predate most people currently writing about AI agents.
That’s the part I keep relearning. The model’s output was never the problem. The fourteen messages were well-researched, correctly disclosed, priced inside their envelope. What stood between the system and its first contact with the outside world was mundane distributed-systems failure at the seams: the places where the agent touches sessions, third-party libraries, queues, and other processes. Smarter models don’t fix seams. Boring engineering fixes seams.
In fairness, the model also did the boring engineering. The protocol forensics, the watchdog, the lockfile: I read the diffs over coffee and said yes. Which makes the division of labour about as clear as it gets: the machine owns the chase, I own the consequences and the AED 300 envelope.
The first replies should land today. Different failure modes entirely. I’ll write those up too.