When AI Gets Desperate
Learn the AI debugging stories and lessons from two of the trickiest bugs at Monaco: a hang that struck once every hundred jobs, and an expensive phantom script that nobody ever ran.

The mysterious hang
Why does it only happen to my job?
Every few days, a random CSV upload job would hang forever.
No rows written. No exception raised. The log stopped after dispatching Celery tasks. But hundreds of other flows dispatch Celery tasks the same way all day long, and none of them ever hit this.
The temporary fix was easy: rerun the job and it worked. So I set up a monitor to detect the hang and alert me. As for the root cause: I tried several times to have AI debug it, and never got a satisfying answer.
The biggest tell I had, or thought I had, was that this only ever happened to the CSV upload job. That made it hard to suspect the shared infrastructure: Celery, Redis, and Prefect all serve every other flow perfectly. It had to be something about how this code was written. I also pushed AI to think in the same direction, but mostly I just reacted to its progress and let it explore.
There was one other tell, though I didn't know it was a tell yet. Every hung pod held exactly 216 bytes unread on its connection to Redis. Not roughly 216. Exactly, every time.
Debugging the wrong process
It turns out my AI had been debugging the wrong process the whole time.
Our flow runs under prefect flow-run execute, which is PID 1 in the pod. That felt like the obvious thing to attach a profiler to, so my AI did, repeatedly. Every py-spy dump and every pystack trace came back the same way: idle, no application frames, parked on waitpid.
prefect flow-run execute is a supervisor. The actual flow runs in a child process.
From those dumps of the wrong process, my AI started to rule things out with evidence. RabbitMQ flow control: no, the broker log was empty. A blocked socket write: no, the socket was ESTABLISHED with tx_queue=0. Redis degradation: no, zero evictions anywhere. Pod or worker failure: no OOM, no eviction, first worker churn 39 minutes after the hang. Queue saturation: one hang happened against a queue idle for 35 minutes.
Eventually it reached a conclusion: the group-of-chain dispatch shape was to blame. It came with a detailed mechanism, and it matched my own observation that CSV upload was the only flow dispatching work this way. So it suggested I add more logs and restructure the dispatching code. I came close to accepting it and I even drafted the fix, but I hesitated. My engineering hunch told me this was a stretch, even though I couldn't find any solid evidence to refute it. Verifying it would also be expensive: given how rarely the bug fired, a quiet week after deploying the fix would prove nothing.
So I tried again another day and revisited everything we had collected. When I finally asked, "what about other processes?", my AI checked them and dumped the right child process. Then everything became easy. py-spy --locals named the culprit outright: it was a deadlock inside Celery.
apply_async → send_task
→ on_task_call (celery/backends/redis.py)
→ consume_from → _consume_from → subscribe (redis/client.py)
→ execute_command → with self._lock: ← ACQUIRED
→ send_command → send_packed_command → sendall
→ [garbage collection]
→ AsyncResult.__del__ (celery/result.py)
→ backend.remove_pending_result (celery/backends/asynchronous.py)
→ on_result_fulfilled
→ ResultConsumer.cancel_for (celery/backends/redis.py)
→ pubsub.unsubscribe (redis/client.py)
→ execute_command → with self._lock: ← DEADLOCKCelery subscribes to a pub/sub channel on every task publish, holding a lock while it does network I/O. If garbage collection fires during that write and finalizes a discarded AsyncResult, the finalizer tries to unsubscribe and reaches for the lock the same thread is already holding. Same thread, non-reentrant lock, permanent block. The window is narrow, which is why it only landed once in a hundred runs, and it needs a specific code pattern to line up at all: publish a task, consume its result, drop it, then publish again. CSV upload was the only flow we had that did that.
It was never our code. We only call apply_async() and .get(), the two most basic APIs the library exposes. I filed it upstream as celery/celery#10477, which is already fixed and milestoned for 5.7.0, with the full mechanism there for anyone who wants it.
The phantom script
Months earlier, another bug took a similar detour, and this one cost us real money.
We ingest email through the Gmail API by polling every two minutes, each poll asking for one time-boxed slice of the mailbox and advancing a cursor as it goes. Steady, boring, one page at a time.
The system had been functioning well for months. Then one day we found that our email-related computation cost for the previous week was much higher than normal. I started an AI agent to debug it, and pretty quickly we found the suspect pattern in our logs: several times that week, an hour of furious backward fetching, one connection pulling mail as fast as the API would hand it over, then going quiet again.
Three things stood out. They came in bursts, not as a trend. They only hit some mailboxes, with no pattern by org or by signup date. And the cursor never moved: it still pointed at the window we expected it to be working through, while the database held mail far older than the cursor timestamp.
From the pattern and how our fetching system was written, my AI ruled out every other possibility and landed on one conclusion: someone was running a backfill script in a production pod. The eliminations were sound since nothing on the normal path can bypass the cursor based time-boxed fetch. So something off the normal path must have done it.
It was hard to prove though. A manual script leaves no trace. No deploy, no commit, no request log. Nothing that records a human typing a command into a pod.
I was half-convinced again, but I couldn't form another explanation, and people were waiting for an answer, so I accepted it. To confirm the theory, I asked all the potential authors of this backfill script, but everyone denied it. I didn't give up on this explanation though. There was one teammate who was out that week, so he became my suspect, and I even mentioned his name when people asked who ran the script.
When he came back, he said no.
It wasn't surprising. Part of me already knew, and just didn't want to face it. It forced me back into the investigation, and eventually we found the real root cause.
One expensive line
It turned out we never understood how Gmail API's server-side cursor works.
It was our pagination. Gmail returns at 100 message IDs per page by default and hands back a pageToken for the rest. Our code passed that token on the next call, but dropped the query filter when it did. A pageToken, though, is an opaque server-side cursor into the mailbox, not a stored copy of the query. With nothing left to constrain it, page two doesn't mean "the rest of that hour." It means "keep walking from here, unfiltered, newest to oldest," and it keeps walking until the mailbox runs out or the task hits its time limit.
That one missing filter explains every tell. A dense hour with more than 100 messages opened a second page, and that page became an hour-long walk through the whole inbox. That's the burst. Most mailboxes never fill a page, so only large ones with very dense email distribution ever trigger it, and mailbox size doesn't correlate with org or signup date. That's the randomness. And the cursor only advances between chunks, so all through the walk it kept honestly reporting the one hour it had been asked to fetch. That's the untouched cursor.
The fix was one line: restate the query on every page. But the cost was non-trivial because of all the token spent on the downstream computation. Learning from this, we built an entire set of email ingestion reconciliation and cleanup flows, so we verify that ingestion is complete and clean instead of blindly trusting that the code just works.

The theories you can't kill
If you push AI hard enough, it will converge on either the truth or a false theory that is expensive and difficult to verify. I call the latter AI desperation.
Both investigations ended in the same place: a theory that explained everything, that I couldn't disprove, and that I couldn't quite believe. The group-of-chain dispatch shape. Someone's backfill script.
Neither could be killed cheaply. Ruling out the dispatch shape meant restructuring the code, shipping it, and then waiting. And a quiet week proves nothing about a bug that lands once in a hundred runs. Ruling out the script meant interviewing every engineer capable of writing one and trusting that nobody misremembered their own week.
That isn't a coincidence; it's a selection effect. Cheap theories die young when they're wrong; you check them in a minute and move on. Expensive ones survive, not because evidence supports them, but because nothing available can kill them. So the longer an investigation runs, the more its surviving candidates have been selected for being unfalsifiable rather than true. And the same trait that keeps them alive is what makes chasing them costly. You pay twice.
And I have to admit, it's very tempting to just accept it, like I did with the phantom script theory. I was only half-convinced, but laziness and social pressure did the rest: investigating further was expensive, and someone was waiting on an answer. AI has the exact same problem, structurally: it is trained to generate an answer instead of returning "I don't know." I was desperate for social reasons, and the model is desperate by construction.
Two half-convinced parties reinforce each other. AI is really good at reasoning, so it can find an explanation path for almost any anomaly you raise, and each one makes you more convinced. Then, once you start following what AI proposes, it shows more confidence and pushes further. That is how I ended up naming a teammate who was on vacation.
Fight AI desperation with your engineering hunch
So what do you do when a desperate AI comes back with a half-convincing theory that is hard to verify, and people are waiting on you for an answer?
No matter how tricky the bug is, there's only one truth. Most of the time, you know it when you hear it. It clicks. That's your engineering hunch.
And its most useful signal is the negative one: the theory that survives every check and still doesn't click.
I don't think only humans have this hunch. AI has it too, but it doesn't use it when it's struggling to debug and you're pushing for an answer. I'm pretty sure it will get good enough at some point, but until then that edge is what keeps us from being misled by AI desperation.
Signs of desperate AI:
- Reaching a conclusion by elimination.
"Everything else is eliminated, so it must be X" is not evidence for X. Both wrong theories here were built exactly this way, and every individual elimination was correct. The list of alternatives was just incomplete, and it always is, because the true cause is the one you haven't thought of yet. - Raising a theory that is hard to verify.
Treat the cost of verification as a warning rather than an inconvenience. Hard-to-kill and wrong is the most common combination at this stage of an investigation, not a coincidence. - Reaching a conclusion by pure code reading.
Some engineers debug by staring at the code. Trust me, it's extremely hard and inefficient. The same is true for AI. You'll often get trapped by a theoretical flaw and miss what actually happened.
Solution:
- Revisit the foundation.
Not the theory, but the inputs underneath it. Is the thing you are measuring the thing you think it is? In the Celery case the answer was sitting in a question nobody asked for weeks: are we even looking at the right process? - Get a new data point.
Another round of reasoning over the same evidence just produces another theory of the same quality. One genuinely new observation ends the argument. Sometimes an indirectly related data point provides the key that opens a brand-new direction. - Try another day, with a fresh mind and a fresh conversation.
The context that talked you into the theory is the same context that will keep you there. Starting over costs you all the accumulated reasoning, and that is exactly the point: it's the cheapest way to drop the anchor. Both root causes here were found on a second attempt, days later, from a blank page.
Among magicians, there's a famous technique called misdirection. It means using intentional suspicious actions to attract your attention so the real trick can be done unnoticed. It's perfectly normal to be misdirected by modern AI's sophisticated traps. Just enjoy the moment of eventually seeing the real trick behind it. 🎩
Keep reading

Scaling Engineering Contributions
Learn how Monaco lets non-engineers ship production code safely, and how it uses automated guardrails and agent review to keep quality high.

Build or Buy? SRE Tools and Slack Bots
Learn why and how Monaco built its own AI SRE Slack bot and how an in-house, security-conscious agent platform delivered faster investigations, broader adoption, and lower costs than point solutions.