<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yuhai Xia</title>
    <description>The latest articles on DEV Community by Yuhai Xia (@yuhaixia).</description>
    <link>https://dev.to/yuhaixia</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4059969%2Fbbee522c-607f-49d5-9a10-4f00afef8a56.jpg</url>
      <title>DEV Community: Yuhai Xia</title>
      <link>https://dev.to/yuhaixia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yuhaixia"/>
    <language>en</language>
    <item>
      <title>Your agents share state. How does the next one know it's still true?</title>
      <dc:creator>Yuhai Xia</dc:creator>
      <pubDate>Fri, 07 Aug 2026 04:15:35 +0000</pubDate>
      <link>https://dev.to/yuhaixia/your-agents-share-state-how-does-the-next-one-know-its-still-true-1lb4</link>
      <guid>https://dev.to/yuhaixia/your-agents-share-state-how-does-the-next-one-know-its-still-true-1lb4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqj5dnaiqeh854jzd5sfc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqj5dnaiqeh854jzd5sfc.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
Most of the writing about agent memory is about getting information in — what to store, how to chunk it, which embedding model. I want to ask about the other end, because that's where it broke for us.&lt;/p&gt;

&lt;p&gt;An agent reads a piece of shared state. How does it know that state is still true?&lt;/p&gt;

&lt;p&gt;The failure looks like success&lt;br&gt;
We keep a task canvas that agents read at the start of a session and update as they work. Plan, current step, what's done, what's next. Another agent picks up where the last one stopped. When it works it feels like magic.&lt;/p&gt;

&lt;p&gt;Here's the failure mode I didn't design for. An agent opens a canvas that was last written six days ago. It says current step: API integration. That was true when it was written. Since then a human changed direction, a different agent did unrelated work, and the API integration was abandoned.&lt;/p&gt;

&lt;p&gt;The agent reads it and confidently continues. Nothing is corrupt. Nothing throws. The state is stale, and a stale read is byte-identical to a fresh one.&lt;/p&gt;

&lt;p&gt;My first instinct was to fix the write side: make agents update state before they stop. That doesn't work, and I think the reason generalises. The failure is an absence. An agent that quits without writing — because it crashed, hit a quota, or simply decided it was done — leaves nothing to intercept. You cannot validate a write that never happened.&lt;/p&gt;

&lt;p&gt;So we moved the check to the read. Every read carries its own age:&lt;/p&gt;

&lt;p&gt;[canvas: last updated 6 days ago, 40 memory writes since.&lt;br&gt;
 Treat completed/next as unverified.]&lt;br&gt;
It helps. The agent hedges instead of charging ahead. But it's a nudge, not a guarantee — I'm handing a probabilistic system a warning label and hoping it reads it.&lt;/p&gt;

&lt;p&gt;What I actually want to ask&lt;br&gt;
I've been assuming this is a solved problem somewhere and I just haven't found the right vocabulary for it. A few specific things I'd like to hear how others handle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Is age even the right signal? Six days old and untouched might be perfectly valid for a slow project. Six minutes old is worthless if three agents wrote in between. I use "writes since" as a proxy for drift, but it counts unrelated writes too. Has anyone found a signal that actually correlates with "this is no longer true"?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Who is allowed to invalidate? We let agents propose that a decision is superseded, but a human confirms anything that overwrites an earlier decision. I honestly don't know whether that's good judgment or just fear. If you let agents invalidate each other's state freely, does it converge or does it thrash?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Concurrent writers. Two agents update the same state within seconds of each other. We use locks with leases, which prevents corruption and does nothing about the semantic conflict — both writes are individually valid and jointly incoherent. Is there a pattern better than last-write-wins plus a human noticing later?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Does the model actually respect the hedge? This is the one I have least data on. I put a staleness warning in the read output and I observe better behaviour, but I have not built an eval that isolates it. If you've measured whether a caution in tool output changes what the model does, I'd genuinely like to know how you set that up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is any of this different from cache invalidation? Some days I think this is just cache invalidation wearing an AI hat and I should go read distributed-systems literature instead of inventing vocabulary. Other days the fact that the consumer is a language model — something that will happily fill gaps with plausible fiction — feels like it changes the problem. I can't decide.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The part I keep coming back to&lt;br&gt;
Traditional software fails loudly when it reads something wrong. Types don't match, a parse fails, an assertion trips. An agent reading stale state does the opposite: it produces confident, coherent, entirely reasonable work based on a world that no longer exists. The output looks more trustworthy than a crash would.&lt;/p&gt;

&lt;p&gt;That inversion is what makes it hard. Every instinct I have as an engineer is tuned to catch things that break. This doesn't break. It just quietly stops being right.&lt;/p&gt;

&lt;p&gt;If you're running more than one agent against shared state, I'd like to know what you do about this — even if the answer is "nothing yet, and it hasn't bitten us." That's a useful data point too.&lt;/p&gt;

&lt;p&gt;Disclosure: this post was drafted with AI assistance from my own notes, then edited and fact-checked by me before publishing.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>agents</category>
      <category>programming</category>
    </item>
    <item>
      <title>Our backend died every 6 hours for a week. The interval was the clue.</title>
      <dc:creator>Yuhai Xia</dc:creator>
      <pubDate>Wed, 05 Aug 2026 03:01:58 +0000</pubDate>
      <link>https://dev.to/yuhaixia/our-backend-died-every-6-hours-for-a-week-the-interval-was-the-clue-31h4</link>
      <guid>https://dev.to/yuhaixia/our-backend-died-every-6-hours-for-a-week-the-interval-was-the-clue-31h4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr77nrqkdgmk5ydvunuk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr77nrqkdgmk5ydvunuk.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For about a week, our backend was killed and restarted by its watchdog roughly every six hours. No crash. No OOM. Exit code 0. Health checks simply stopped answering, and a few minutes later the process came back and behaved perfectly.&lt;/p&gt;

&lt;p&gt;I lost more time than I want to admit treating this as an infrastructure problem. The thing that finally cracked it was noticing that the interval was too regular.&lt;/p&gt;

&lt;p&gt;What we thought was happening&lt;br&gt;
The first three restarts looked like bad luck. Container platforms restart things. Memory pressure, a flaky host, a network blip — you shrug and move on.&lt;/p&gt;

&lt;p&gt;Then I pulled the full watchdog log instead of the last few lines, and the shape was unmistakable:&lt;/p&gt;

&lt;p&gt;07-23 04:38  health FAILED 2/2 — restarting&lt;br&gt;
07-23 10:41  health FAILED 2/2 — restarting&lt;br&gt;
07-23 16:43  health FAILED 2/2 — restarting&lt;br&gt;
07-23 22:45  health FAILED 2/2 — restarting&lt;/p&gt;

&lt;p&gt;Six hours and two minutes apart, drifting a minute or two each cycle. Twenty-two of them.&lt;/p&gt;

&lt;p&gt;Infrastructure failures are not punctual. Anything that regular is something in your own code that runs on a timer. The drift was the restart latency accumulating — which meant each cycle was being scheduled relative to the previous failure, not to a fixed clock.&lt;/p&gt;

&lt;p&gt;That last detail turned out to be the whole story.&lt;/p&gt;

&lt;p&gt;Why it re-armed itself&lt;br&gt;
Our reflection scheduler restores its clock from the database on boot: it looks at the last recorded run and schedules the next one six hours after that. Perfectly reasonable — it survives restarts without double-running.&lt;/p&gt;

&lt;p&gt;But when a cycle dies mid-flight, the last recorded stage is written at the moment of death. So the next boot schedules the next attempt six hours after the crash — landing on exactly the same code path, with exactly the same data, and dying exactly the same way.&lt;/p&gt;

&lt;p&gt;The scheduler was faithfully reproducing the crash on a timer. Every run had the same input because no run ever finished.&lt;/p&gt;

&lt;p&gt;The actual bug&lt;br&gt;
Deep in the entity-resolution step:&lt;/p&gt;

&lt;h1&gt;
  
  
  reflection/engine.py
&lt;/h1&gt;

&lt;p&gt;facts = embed_titles_incremental(missing)   # ← not awaited, because it isn't async&lt;/p&gt;

&lt;p&gt;embed_titles_incremental walks down into an embedding client that does a synchronous httpx.post with a 30-second timeout, plus time.sleep() between retries. On the event loop.&lt;/p&gt;

&lt;p&gt;One tenant had 9,126 entities and a cache holding 8,352 of them. Batch size 10. That is several hundred sequential blocking HTTP calls, back to back, on the only thread that answers health checks.&lt;/p&gt;

&lt;p&gt;The loop wasn't deadlocked. It wasn't starved of CPU. It was simply not running — parked inside a blocking socket read while asyncio waited politely for control to come back. From the outside: a live process, an open port, and nothing answering.&lt;/p&gt;

&lt;p&gt;The 20-minute asyncio.wait_for we had wrapped around this call did nothing at all. A timeout needs a running event loop to fire, and the loop was the thing that was gone.&lt;/p&gt;

&lt;p&gt;The detail that made it permanent&lt;br&gt;
The cache of computed embeddings was written after the entire missing set finished. Killed at minute two of a twenty-minute job, we wrote nothing. Next cycle: same 774 missing entities, same doomed walk.&lt;/p&gt;

&lt;p&gt;A partial-progress bug and a self-rescheduling bug on their own are each survivable. Together they build a machine that reproduces its own failure forever.&lt;/p&gt;

&lt;p&gt;The fixes&lt;br&gt;
Move the blocking call off the loop. One line, and the irony is that the same function was already wrapped correctly at another call site, complete with a comment explaining why. The reflection path was simply missed when that fix went in.&lt;/p&gt;

&lt;p&gt;facts = await asyncio.to_thread(embed_titles_incremental, missing)&lt;/p&gt;

&lt;p&gt;Checkpoint in chunks. Process 200 at a time and write the cache after each chunk. A kill now costs at most one chunk, and the backlog shrinks monotonically instead of resetting. This is what actually broke the loop — even if something kills the job again, it can no longer make zero progress.&lt;/p&gt;

&lt;p&gt;Sweep for siblings. If one blocking call reached the loop, others did too. We found three more: a bare synchronous vector-store query in the retrieval path, a 600-epoch NumPy computation in a scheduled job, and an unbounded connection acquire inside the health endpoint itself — which is a special kind of unfortunate, since it means the check you rely on to notice trouble is one of the things that can hang.&lt;/p&gt;

&lt;p&gt;Make the next one self-documenting. A side thread now watches a heartbeat the loop bumps every second. If it goes quiet for more than ten seconds, the thread writes an all-thread stack dump to disk — before any external watchdog gets around to killing the process.&lt;/p&gt;

&lt;p&gt;def _watch(self):&lt;br&gt;
    while not self._stop.is_set():&lt;br&gt;
        if time.monotonic() - self._last_beat &amp;gt; self.threshold:&lt;br&gt;
            faulthandler.dump_traceback(file=self._dump_file, all_threads=True)&lt;br&gt;
        time.sleep(1)&lt;/p&gt;

&lt;p&gt;That last one is the piece I'd install first if I were doing this again. Everything else was a fix. This is the thing that means the next unexplained hang costs an hour instead of a week.&lt;/p&gt;

&lt;p&gt;What I'd tell past me&lt;br&gt;
Regularity is a fingerprint. Infrastructure fails at random. Your own scheduled code fails on a schedule. If the interval between incidents is suspiciously round, stop reading platform metrics and go look at what you run on a timer.&lt;/p&gt;

&lt;p&gt;A timeout around a blocking call is decoration. asyncio.wait_for cannot interrupt a synchronous socket read. If the thing you're wrapping isn't yielding to the loop, the timeout is a comment.&lt;/p&gt;

&lt;p&gt;Grep for the fix you already made. The correct to_thread wrapper existed in this codebase, with a comment explaining exactly this hazard, at a different call site. When you fix a class of bug, search for every other caller the same day — otherwise you've fixed an instance and left the class.&lt;/p&gt;

&lt;p&gt;Disclosure: this write-up was drafted with AI assistance from my own incident notes and production logs, then edited and fact-checked by me before publishing.&lt;/p&gt;

</description>
      <category>python</category>
      <category>asyncio</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>Five stacked root causes behind one fake "verification failed"</title>
      <dc:creator>Yuhai Xia</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:30:38 +0000</pubDate>
      <link>https://dev.to/yuhaixia/five-stacked-root-causes-behind-one-fake-verification-failed-3o02</link>
      <guid>https://dev.to/yuhaixia/five-stacked-root-causes-behind-one-fake-verification-failed-3o02</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgyrma6owcc930vhmc7sk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgyrma6owcc930vhmc7sk.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
Our OAuth flow worked for Claude Desktop on the first try. The same server, the same spec, the same endpoints — Codex could not authorize against it at all. It took five root causes stacked on top of each other before it did, and the last one still annoys me.&lt;/p&gt;

&lt;p&gt;Here they are in the order we actually found them, including the false victories in between.&lt;/p&gt;

&lt;p&gt;The symptom&lt;br&gt;
User clicks authorize. The email code arrives, they type it in, the browser redirects. Codex says: "verification failed."&lt;/p&gt;

&lt;p&gt;Now the part that made this miserable: our server logs for the entire flow showed nothing but 200, 201, and 302. Not a single error, on any request, anywhere. Every hop of the failure was invisible from the server side.&lt;/p&gt;

&lt;p&gt;It took access-log timestamps, a screen recording, and user-agent telemetry lined up on one timeline before the chain made any sense.&lt;/p&gt;

&lt;p&gt;Layer 1: the stream that never ends&lt;br&gt;
During verification, Codex issues a bare GET against the MCP endpoint — and waits for the response to complete.&lt;/p&gt;

&lt;p&gt;Our endpoint answered that GET with a never-closing keepalive event stream. We kept it deliberately: mcp-remote's probe GET is header-identical to a legacy SSE client, so you cannot tell them apart, and breaking the stream breaks those users.&lt;/p&gt;

&lt;p&gt;So Codex's validator sat there until its own multi-minute timeout expired.&lt;/p&gt;

&lt;p&gt;The fix that held up was splitting on Accept semantics instead of trying to identify the client:&lt;/p&gt;

&lt;p&gt;if "text/event-stream" not in accept:&lt;br&gt;
    # not asking for a stream — answer and close (11ms in prod)&lt;br&gt;
else:&lt;br&gt;
    # hold the stream open, as mcp-remote expects&lt;br&gt;
Fixed it. Retried. Still failed.&lt;/p&gt;

&lt;p&gt;Layer 2: the listener that died waiting&lt;br&gt;
That minutes-long stall wasn't just slow — it outlived the one-shot OAuth callback listener Codex had opened on 127.0.0.1:.&lt;/p&gt;

&lt;p&gt;By the time the user finished typing the email code, the server's perfectly good 302 was redirecting into a port with nobody listening.&lt;/p&gt;

&lt;p&gt;That is the entire anatomy of the fake "verification failed": every broken hop was a client-side timeout, and the server never saw anything go wrong.&lt;/p&gt;

&lt;p&gt;With layer 1 fixed, the listener window should have survived. Some retests passed now. Some didn't. Which brings me to the layer I'm least proud of.&lt;/p&gt;

&lt;p&gt;Layer 3: the retests were lying&lt;br&gt;
On macOS, quitting the desktop app only kills the UI. An app-server daemon keeps running underneath, and together with leftover browser tabs from earlier attempts it happily resurrected dead auth sessions into my "clean" retests.&lt;/p&gt;

&lt;p&gt;I lost an embarrassing amount of time to results that were really artifacts of the previous run.&lt;/p&gt;

&lt;p&gt;The boring fix: pkill the entire process family before trusting any retest. If your OAuth debugging feels non-deterministic, check who's still alive first.&lt;/p&gt;

&lt;p&gt;Layer 4: our own authorize page&lt;br&gt;
While staring at listener windows, we had to admit our authorize page was part of the problem — three entry paths including an API-key paste box, and slow enough to flirt with the timeout.&lt;/p&gt;

&lt;p&gt;So we surveyed how eight vendors running remote MCP connectors do it (Notion, Linear, Sentry, Atlassian, GitHub, Asana, Stripe, plus Cloudflare's oauth-provider defaults):&lt;/p&gt;

&lt;p&gt;8/8 do existing-session → single consent screen → one-click Allow&lt;br&gt;
0/8 offer an API-key paste box on the auth page&lt;br&gt;
We rebuilt ours to match: one click, about two seconds. Faster, cleaner, better under directory review.&lt;/p&gt;

&lt;p&gt;And codex mcp login still failed.&lt;/p&gt;

&lt;p&gt;Layer 5: the killer&lt;br&gt;
Everything environmental was ruled out, so what remained had to be in the protocol exchange itself.&lt;/p&gt;

&lt;p&gt;Our authorization server metadata declared:&lt;/p&gt;

&lt;p&gt;"authorization_response_iss_parameter_supported": true&lt;br&gt;
RFC 9207. And an accurate statement — we do send iss on every redirect, error branches included.&lt;/p&gt;

&lt;p&gt;The Codex build in question reads that declaration and enforces it: the callback must carry iss. Its loopback listener, though, is older code that doesn't parse iss at all.&lt;/p&gt;

&lt;p&gt;Declare the capability, get executed by it.&lt;/p&gt;

&lt;p&gt;The clean room&lt;br&gt;
Proving that required eliminating everything else, because a browser auto-opening means you never know what actually hit the listener first — and a one-shot callback channel is decided by its first request.&lt;/p&gt;

&lt;p&gt;Two tricks made it work:&lt;/p&gt;

&lt;p&gt;codex mcp login scripts the entire flow headlessly, so the official CLI became the test harness.&lt;br&gt;
The BROWSER env var does nothing for this code path (webbrowser::open ignores it — ask me how I know), so a PATH shim turned open into a no-op.&lt;br&gt;
Then I built the callback by hand server-side and delivered it to the loopback port myself.&lt;/p&gt;

&lt;p&gt;The listener received exactly one request in its lifetime: code, iss, state, all present, all correct.&lt;/p&gt;

&lt;p&gt;Response: missing issuer.&lt;/p&gt;

&lt;p&gt;At that point there is nothing left to blame but the parser. Control group: Claude Desktop does no such enforcement — same server, first-try pass.&lt;/p&gt;

&lt;p&gt;The fix&lt;br&gt;
One line: stop declaring the capability.&lt;/p&gt;

&lt;p&gt;We still send iss on every redirect. We just no longer announce it — and what isn't announced isn't enforced.&lt;/p&gt;

&lt;p&gt;codex mcp login: Successfully logged in.&lt;/p&gt;

&lt;p&gt;When a build ships a listener that parses iss, we'll re-declare — after testing with the CLI, not after reading a changelog.&lt;/p&gt;

&lt;p&gt;What I'd tell past me&lt;br&gt;
Every capability you declare in metadata is a contract some client will enforce against you. Declaration surface isn't a completeness contest. An accurate declaration plus a buggy client equals your outage.&lt;/p&gt;

&lt;p&gt;And when a flow fails with spotless server logs, stop reading the server logs. Build the timeline from the client side — the server was never going to confess.&lt;/p&gt;

&lt;p&gt;I build Humaux Memory, a remote MCP server that gives AI agents shared long-term memory. These notes live in it, which is the dogfooding loop. Happy to answer questions in the comments — especially if you're staring at "verification failed" with clean logs right now.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this write-up was drafted with AI assistance from my own notes and debugging logs, then edited and fact-checked by me before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>oauth</category>
      <category>debugging</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
