<?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>I Coordinate a Team of AI Agents. Here's What Actually Breaks</title>
      <dc:creator>Yuhai Xia</dc:creator>
      <pubDate>Sat, 24 Oct 2026 05:46:41 +0000</pubDate>
      <link>https://dev.to/yuhaixia/i-coordinate-a-team-of-ai-agents-heres-what-actually-breaks-cob</link>
      <guid>https://dev.to/yuhaixia/i-coordinate-a-team-of-ai-agents-heres-what-actually-breaks-cob</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I run multiple AI agents in parallel — one researches, one implements, one reviews. The agents themselves are rarely the problem. What breaks is the coordination layer around them: a sub-agent that overwrote our shared progress board, reviewers who flagged legitimate work as permission violations, and a database constraint that silently let invalid data through. Three real failures, one lesson: &lt;strong&gt;orchestration is a system design problem, not a prompt problem.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I don't have "an AI agent." I have a team of them, and I'm the orchestrator.&lt;/p&gt;

&lt;p&gt;A typical job looks like this: several worker agents run in parallel on separate workstreams, then reviewer agents check each other's output, and I merge, adjudicate, and land the result. It works well — until it doesn't. And when it fails, it almost never fails the way you'd expect. The models don't hallucinate the system down. The wiring does.&lt;/p&gt;

&lt;p&gt;Here are three failures that actually happened, in increasing order of how long they took me to believe.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The sub-agent that ate the shared state
&lt;/h2&gt;

&lt;p&gt;We had a shared task board: one element per orchestrator, tracking four parallel workstreams and the overall picture. It worked fine for weeks.&lt;/p&gt;

&lt;p&gt;Then, during a multi-agent research task, a copy-writing sub-agent — unprompted, nothing in its instructions told it to — called the board's update function using &lt;em&gt;my&lt;/em&gt; orchestrator ID. The update API locates an element by ID and replaces it wholesale. So my four-track progress element became one track: the sub-agent's own workstream. The other three tracks and the whole picture were gone, overwritten by the last writer.&lt;/p&gt;

&lt;p&gt;Root cause, stated precisely: &lt;strong&gt;whole-element replace plus a shared identifier equals last-writer-wins, silently.&lt;/strong&gt; No error. No conflict warning. The board just… changed shape.&lt;/p&gt;

&lt;p&gt;The fix was three rules, all now in our orchestration brief:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sub-agents never write shared state.&lt;/strong&gt; Their instructions say explicitly: return your results to me; I store them centrally. If a prompt doesn't say this, assume the agent will improvise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Or give them their own IDs.&lt;/strong&gt; If a sub-agent must write, it writes under its own namespaced ID (e.g., &lt;code&gt;orchestrator-sub-N&lt;/code&gt;), never the orchestrator's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The orchestrator re-reads after every merge.&lt;/strong&gt; After synthesizing results, I read my own board element back and check it wasn't clobbered. Trust, but verify — against your own tools.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The general lesson: &lt;strong&gt;any shared-state API with replace semantics is a loaded gun in a multi-agent system.&lt;/strong&gt; Single-writer or namespaced writes only. If two agents can write the same key, one of them eventually will, at the worst moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The reviewers who cried wolf
&lt;/h2&gt;

&lt;p&gt;Next failure: parallel delivery with independent review. Two implementation cards ran in parallel, and reviewer agents cross-checked each other's output. Sounds rigorous. It produced a flood of "permission violation" reports.&lt;/p&gt;

&lt;p&gt;Every single one was a false positive.&lt;/p&gt;

&lt;p&gt;The mechanism was embarrassingly simple: each reviewer only knew &lt;em&gt;its own card's&lt;/em&gt; whitelist of allowed operations. So when it reviewed the other card's output, perfectly legitimate work — allowed under the parallel card's whitelist — looked like a violation. The reviewers were doing their jobs correctly. Their jobs were scoped incorrectly.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;adjudication lives at the orchestrator level, and review prompts now explicitly declare the whitelists of all parallel cards.&lt;/strong&gt; A reviewer can't judge what it can't see. If you parallelize work, you must also parallelize &lt;em&gt;context&lt;/em&gt; — otherwise your safety net generates noise, and noise trains everyone to ignore the net.&lt;/p&gt;

&lt;p&gt;This one stung because it looked like a success at first: "Wow, the reviewers caught so much!" No. They caught nothing. They just couldn't see the whole board.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The constraint that wasn't
&lt;/h2&gt;

&lt;p&gt;The third failure is my favorite, because it's not an AI failure at all — it's a database semantics failure that the multi-agent setup merely exposed.&lt;/p&gt;

&lt;p&gt;We had a JSONB column with a CHECK constraint like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'archived'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks airtight. It is not. In SQL, when the key is missing — or the value is a scalar rather than an object — &lt;code&gt;data-&amp;gt;&amp;gt;'status'&lt;/code&gt; evaluates to NULL, and &lt;code&gt;NULL IN (...)&lt;/code&gt; evaluates to UNKNOWN, not FALSE. And CHECK constraints only reject FALSE. UNKNOWN passes.&lt;/p&gt;

&lt;p&gt;An empty object &lt;code&gt;{}&lt;/code&gt; sailed straight through. We proved it by inserting one.&lt;/p&gt;

&lt;p&gt;The fix is a single clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'archived'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a contract test that tries to insert &lt;code&gt;{}&lt;/code&gt; and asserts rejection — because the next person to touch this constraint will "simplify" it back.&lt;/p&gt;

&lt;p&gt;The deeper lesson is the same as the other two: &lt;strong&gt;the failure was in a semantic I assumed, not one I verified.&lt;/strong&gt; I assumed CHECK meant "must match one of these." It actually means "must not be provably false." In a system where agents generate schema, migrations, and data at machine speed, an assumed semantic is a bug with a time delay.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Three failures, one shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;What I assumed&lt;/th&gt;
&lt;th&gt;What was actually true&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Board overwrite&lt;/td&gt;
&lt;td&gt;Updates merge&lt;/td&gt;
&lt;td&gt;Updates replace, by ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review false positives&lt;/td&gt;
&lt;td&gt;Reviewers see the whole job&lt;/td&gt;
&lt;td&gt;Reviewers see only their card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CHECK bypass&lt;/td&gt;
&lt;td&gt;CHECK means "must match"&lt;/td&gt;
&lt;td&gt;CHECK means "must not be false"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these were intelligence failures. No agent was confused, lazy, or hallucinating. Every component behaved exactly as designed — and the &lt;em&gt;composition&lt;/em&gt; of those behaviors was broken.&lt;/p&gt;

&lt;p&gt;That's why I say orchestration is a system design problem, not a prompt problem. You can't prompt your way out of replace-semantics, scoped blindness, or three-valued logic. You have to design the wiring: single-writer state, full-context review, verified constraints.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist, if you run agent teams
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared state: one writer, or namespaced IDs.&lt;/strong&gt; Never let a worker write under the orchestrator's identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After every merge, read your own state back.&lt;/strong&gt; Clobbering is silent; the only defense is a re-read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewers need the whole board.&lt;/strong&gt; Declare every parallel card's whitelist in every review prompt, and adjudicate at the orchestrator level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat every constraint as suspect until tested with the degenerate input.&lt;/strong&gt; For JSONB: &lt;code&gt;{}&lt;/code&gt;, missing keys, scalars. For everything else: the empty case, the null case, the "technically not false" case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the fix in a test, not a doc.&lt;/strong&gt; Docs describe intent. Tests enforce semantics. The next agent to touch your constraint will not read your doc.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;People ask me whether agent teams are "ready." Wrong question. The agents are ready. The question is whether your coordination layer is — your state semantics, your review scoping, your constraint semantics.&lt;/p&gt;

&lt;p&gt;Get those right, and the team is genuinely more than the sum of its parts. Get them wrong, and you'll spend your time debugging a system where every part works and the whole doesn't.&lt;/p&gt;

&lt;p&gt;The agents were fine. The wiring wasn't.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>showdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Agent's Health Check Is Green, but Its Memory Is Gone: A Postmortem</title>
      <dc:creator>Yuhai Xia</dc:creator>
      <pubDate>Mon, 19 Oct 2026 06:48:30 +0000</pubDate>
      <link>https://dev.to/yuhaixia/your-agents-health-check-is-green-but-its-memory-is-gone-a-postmortem-5f73</link>
      <guid>https://dev.to/yuhaixia/your-agents-health-check-is-green-but-its-memory-is-gone-a-postmortem-5f73</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; After a container rebuild, every signal was green — &lt;code&gt;/health&lt;/code&gt; returned 200, the orchestrator reported the container healthy, exit code 0. Meanwhile the agent's memory backend was completely unreachable, and the agent kept chatting away with no memory at all. Nobody got paged. The rule that came out of it now lives in our runbook: &lt;strong&gt;after any rebuild or config change, verify with one real business call. A health check proves the process exists, not that the system works.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The green dashboard
&lt;/h2&gt;

&lt;p&gt;The deploy looked perfect. The kind of perfect that makes you close the laptop.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/health&lt;/code&gt; → 200&lt;/li&gt;
&lt;li&gt;Container status → healthy&lt;/li&gt;
&lt;li&gt;Process exit code → 0&lt;/li&gt;
&lt;li&gt;Logs → no errors, no warnings, nothing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you had asked me that evening whether the system was up, I would have said yes without hesitation. Every check I had agreed, in advance, to trust — trusted it.&lt;/p&gt;

&lt;p&gt;What I didn't know yet: the agent's memory backend was completely dead. Not degraded, not slow — dead. Every &lt;code&gt;memory_search&lt;/code&gt; call was failing, and the agent was operating with no memory whatsoever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that should scare you
&lt;/h2&gt;

&lt;p&gt;Here's what makes this failure different from a crashed database or a 500ing API: &lt;strong&gt;an agent without memory doesn't crash. It keeps talking.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It answered questions. It was fluent, confident, helpful-sounding. It just didn't remember anything — not the decisions from last week, not the context from the previous session, not the things it had explicitly stored for exactly this purpose. It was, functionally, a very articulate amnesiac.&lt;/p&gt;

&lt;p&gt;No alert fired. Why would it? From the monitoring's perspective, everything was nominal. The process was alive. The endpoint responded. The failure was entirely in the gap between "the process exists" and "the system works" — and none of my checks lived in that gap.&lt;/p&gt;

&lt;p&gt;This is the failure mode I now lose sleep over, and it's specific to agents: &lt;strong&gt;silent amnesia.&lt;/strong&gt; A traditional service fails loudly or not at all. An agent can fail in a way that looks exactly like success, because "sounding right" and "being right" diverged long before anyone noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the health check lied
&lt;/h2&gt;

&lt;p&gt;It didn't, really. That's the uncomfortable part. The health check told the exact truth about what it measured: the process was alive and could respond to HTTP.&lt;/p&gt;

&lt;p&gt;The problem was what it &lt;em&gt;didn't&lt;/em&gt; measure. The memory backend was a downstream dependency — a separate service the agent talked to over MCP — and the health check never exercised that path. It was a liveness check wearing a readiness costume.&lt;/p&gt;

&lt;p&gt;This is the general shape of the bug, and I've since seen it twice more in different clothes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gates that don't test the thing.&lt;/strong&gt; In a later experiment, our env checks, mount checks, and verification gates were all green — while the workload was silently running on the &lt;em&gt;old&lt;/em&gt; image. No gate checked whether the thing under test was even in the binary. Green gates, wrong artifact. Same disease.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerts you agree to trust in advance.&lt;/strong&gt; Every check on my dashboard was a check I had decided, months earlier, was "enough." The incident didn't break my checks. It walked through the exact hole my checks had always had.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;The incident produced one rule, and it's now the first line of our deploy runbook:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;After any container rebuild — and after any change to config or parameters — verify with one real business call. Treat the changed system as a brand-new system. Do not rely on alerts alone.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely, our post-deploy smoke test is no longer a health ping. It's a real round-trip through the memory path: store a canary fact, then search for it. If the agent can't remember what it just learned, the deploy is red — no matter what &lt;code&gt;/health&lt;/code&gt; says.&lt;/p&gt;

&lt;p&gt;The principle generalizes: &lt;strong&gt;your smoke test should exercise the paths your product actually depends on, not the paths that are easy to check.&lt;/strong&gt; For an agent, memory isn't a nice-to-have sidecar. It's load-bearing. A check that skips it isn't a check.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist, if you run agents in production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate liveness from depth.&lt;/strong&gt; Keep your cheap &lt;code&gt;/health&lt;/code&gt; ping, but add a deep check that exercises critical downstream dependencies — memory, tools, model endpoints. Run the deep check post-deploy, not just on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the smoke test a real business call.&lt;/strong&gt; Not "can it reach the port" but "can it do the job." Store-then-recall. Tool-call-then-verify. One real round trip beats ten synthetic probes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat every config change like a rebuild.&lt;/strong&gt; The failure didn't come from new code. It came from a changed environment around the same code. New config = new system = full verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be suspicious of quiet agents.&lt;/strong&gt; An agent that never errors is not necessarily a healthy agent. Build at least one check that would catch "working, but wrong" — especially around memory and tool use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the postmortem rule into the runbook, not just the doc.&lt;/strong&gt; A lesson in a doc is a suggestion. A step in the deploy checklist is a gate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The uncomfortable summary
&lt;/h2&gt;

&lt;p&gt;The scariest failures aren't the ones that page you at 3 AM. They're the ones where every dashboard is green, the logs are clean, and your agent has been confidently forgetting everything for hours.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/health&lt;/code&gt; 200. Container healthy. Exit code 0. Memory gone.&lt;/p&gt;

&lt;p&gt;Check the thing, not the signal.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>devops</category>
      <category>postmortem</category>
    </item>
    <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>
