<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Failing Closed]]></title><description><![CDATA[Musings, technical dives, and incoherent ramblings of a mad-man, focused on agentic engineering, scalable architecture, and quality observable code.]]></description><link>https://www.sperg.dev</link><image><url>https://substackcdn.com/image/fetch/$s_!xe_p!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0106609-0e83-41c4-9c0c-a9e5ea473f4f_500x500.png</url><title>Failing Closed</title><link>https://www.sperg.dev</link></image><generator>Substack</generator><lastBuildDate>Sun, 19 Jul 2026 10:40:18 GMT</lastBuildDate><atom:link href="https://www.sperg.dev/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Pat]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[packet64@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[packet64@substack.com]]></itunes:email><itunes:name><![CDATA[Pat]]></itunes:name></itunes:owner><itunes:author><![CDATA[Pat]]></itunes:author><googleplay:owner><![CDATA[packet64@substack.com]]></googleplay:owner><googleplay:email><![CDATA[packet64@substack.com]]></googleplay:email><googleplay:author><![CDATA[Pat]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Don’t call us, we’ll call you]]></title><description><![CDATA[Multi-agent systems are work queues, not call graphs!]]></description><link>https://www.sperg.dev/p/dont-call-us-well-call-you</link><guid isPermaLink="false">https://www.sperg.dev/p/dont-call-us-well-call-you</guid><dc:creator><![CDATA[Pat]]></dc:creator><pubDate>Sat, 18 Jul 2026 00:32:25 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!wr8N!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first multi-agent thing I built was a DAG. Planner reads the issue and calls the executor. Executor writes code and calls the reviewer. Reviewer returns pass or fail, and on fail we loop back to the executor with the review attached. Three nodes, two edges, one retry. Pretty straight forward graph - I could draw it on a whiteboard. I could test every node in isolation. It worked.</p><p>Then it got a task where the <em>spec</em> was wrong.</p><p>The executor did exactly what it was told, the reviewer correctly said no, and the executor went back and did the same wrong thing again. Only, slightly differently.. Until the turn budget ran out. \sigh</p><p>Nobody was confused. Every node did its job. The problem was that the only edge out of the reviewer went to the executor, because when I drew the thing, why would there be an edge back to the planner? The planner had already planned. That was step one. We were past that.</p><p>And that&#8217;s the bug, and it isn&#8217;t in any of the nodes.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!wr8N!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!wr8N!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 424w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 848w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1272w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!wr8N!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png" width="1024" height="559" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ebab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:559,&quot;width&quot;:1024,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:631549,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://packet64.substack.com/i/207491686?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!wr8N!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 424w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 848w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1272w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg role="img" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><title></title><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>A script terminates when it runs out of steps. An agent <em>replans</em> when it runs out of steps.</h2><p>That&#8217;s the whole difference. Everything else is plumbing we&#8217;ve all built a dozen times: a queue, a worker pool, a state store, retries with backoff. If you&#8217;ve written a job runner, you have all the parts. What you&#8217;re missing is one loop boundary in a different place.</p><p>I want to be clear that the pipeline instinct isn&#8217;t wrong. It&#8217;s the correct first move. It&#8217;s legible, it&#8217;s cheap, and for most of what people call &#8220;agents&#8221;, it&#8217;s also the last move they should make. The pipeline stops being right at a specific moment, and it&#8217;s worth being able to name that moment rather than reaching for the loop (because <a href="https://thoughtsbyjoshmabry.substack.com/p/whats-in-a-loop">loops are what serious people build</a>).</p><h2>The call graph is the problem</h2><p>Here&#8217;s the reframe that took me an embarrassingly long time to wrap my head around: <strong>the agents never call each other</strong>.</p><p>That&#8217;s literally it - if there&#8217;s one thing you take away from this post, it&#8217;s that. Internalize it.</p><h3>The Misnomer</h3><p>&#8220;Agent-to-agent&#8221; is a misnomer that smuggles an architecture in through the name. It sounds like a protocol between peers, right? </p><p>Well, it&#8217;s not. It&#8217;s agent, then state, then agent. Nobody&#8217;s phone rings. We&#8217;re not passing notes in math class - each node reads from a shared state, writes an artifact back, and a <em>dispatcher</em> decides who runs next.</p><p>And thus, here we are . It&#8217;s not a DAG - it&#8217;s a work queue with a scheduler. Not so scary now, right? The only interesting difference is that one worker type is allowed to enqueue new work, and its enqueue decision is a model output instead of a branch you wrote at 2am on a Tuesday six weeks ago after a few beers (not unlike this post).</p><p>That&#8217;s what the &#8220;edges&#8221; are, by the way. Every edge in your DAG is a decision frozen at authoring time. <code>if review_failed: retry()</code> is a guess about a situation you had not yet seen, made by a person who had less information than the system will have at the moment it matters. My reviewer-to-executor edge was a guess that review failures mean the code is wrong. Usually true, yes. But .. not always true. And the DAG has no way to find out, because the guess is load-bearing structure.</p><p>Delete the edges and the decision moves to runtime, where the information actually is.</p><h2>Four node types, and one rule for placing them</h2><p>Four kinds of node: planner, executor, verifier, reviewer. Their signatures tell you most of what you need, and the rule for placing them is simple.</p><blockquote><p><strong>If a node&#8217;s output is a decision, it&#8217;s frontier. </strong></p><p><strong>If it&#8217;s an artifact with a deterministic check, it&#8217;s the cheap model.</strong></p></blockquote><p>Decisions aren&#8217;t testable. That&#8217;s not a limitation to engineer around, it&#8217;s the criterion. &#8220;Should we retry or decompose?&#8221; doesn&#8217;t have an assert. But &#8220;Does <code>pytest tests/test_auth.py::test_expired_token</code> exit zero?&#8221; does. Route on that property, and the split falls out of the architecture instead of being a cost optimization you bolted on afterward.</p><h3>Planner: <code>state -&gt; [task]</code></h3><p>The only node with agency. It reads the entire history and decides what happens next, which makes it the one place in the system where &#8220;it depends&#8221; is an acceptable answer.</p><p>Two things people get wrong here. It runs on empty queue, not on schedule, and not on failure. There&#8217;s no <code>on_error: replan()</code> hook, because replanning isn&#8217;t an error path, it&#8217;s the normal path that happens to also cover errors. And it doesn&#8217;t plan the whole job. It plans the next chunk, which is usually one task, occasionally three, and never seventeen. A planner that emits seventeen tasks has quietly turned back into a DAG author, except now the DAG is written by something with worse judgment than you and no ability to draw it on a whiteboard.</p><p>Use the best model you can afford. This is where the money goes.</p><h3>Executor: <code>task -&gt; artifact</code></h3><p>Takes one task, produces one artifact. Makes no decisions, routes nothing, and never gets asked what should happen next.</p><p>Cheapest model that can hold a tool loop without falling over, which is a lower bar than it was a year ago and is the entire subject of the follow-up post. I&#8217;m a huge fan recently of <a href="https://huggingface.co/protoLabsAI/Ornith-1.0-35B-FP8">protoLabs' quant of Ornith 1.0 35B @FP8</a></p><h3>Verifier: <code>artifact -&gt; verdict</code></h3><p>The one I&#8217;d tattoo on people: <strong>the verifier is code.</strong> Sear this into your memory. It is not a model. Not a model with a really good prompt. Not a model with a really good prompt and structured output. <em>It. Is not. A model!</em></p><p>The instant the verifier is a model, you&#8217;ve rebuilt self-assessment, and self-assessment is the thing the whole apparatus exists to avoid. Done means verified <em>deterministically</em> or it doesn&#8217;t mean anything. An executor that grades its own homework is a very expensive way to generate confident nonsense, and it will do it politely, in JSON, with a <code>"confidence": 0.946</code>.</p><p>What this looks like in practice is unglamorous, and that&#8217;s the point. A shell command and an expected exit code. A schema validation. A diff that has to touch a file in the allowed set. <code>grep -c</code> on a log. If you can&#8217;t write the check, you don&#8217;t have an acceptance criterion, you have a vibe, and the planner shouldn&#8217;t have emitted that task in the first place.</p><h3>Reviewer: <code>artifact -&gt; judgment</code></h3><p>Reads artifacts that already passed the gate and forms an opinion about them. Use a frontier model, because opinions are decisions wearing a different smoking jacket.</p><p>A judgment is not a verdict and it&#8217;s worth keeping the words apart. A verdict is mechanical: the test passed, the build is green, the schema validated. It&#8217;s a fact, and code produces it. A judgment is an opinion, and opinions come in flavors: the fix mutes the symptom instead of curing it, the abstraction won't survive the next feature, the code is technically correct and morally a crime. Both are useful. Only one of them is allowed to gate anything.</p><p>The reviewer doesn&#8217;t route either. It records, same as everyone else, and the planner reads the record on the next tick and decides whether &#8220;morally a crime&#8221; is worth another three tasks or whether we ship it and open an issue.</p><h2>The loop</h2><p>Take a look at this conceptual python. </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;233cb788-fd1b-4330-9888-ca89119ac3b3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">def run(goal, budget):
    state = State(goal=goal)
    queue = deque()

    while not goal_satisfied(state):
        if budget.exhausted():
            raise BudgetExhausted(state.summary())

        if not queue:
            tasks = plan_next_tasks(state)
            if not tasks:
                raise NoViablePlan(state.summary())
            queue.extend(tasks)

        task = queue.popleft()
        worker = worker_for(task.capability)
        artifact = worker.run(task, state.context_for(task))
        verdict = verify(artifact, task.acceptance)

        state.record(task, artifact, verdict)
        budget.charge(worker.cost)

    return state
</code></pre></div><p>Now look at what isn&#8217;t there.</p><p>There are no edges. There&#8217;s no <code>if verdict.failed: retry()</code>. There&#8217;s no routing table, no conditional handoff, no state machine enum with <code>AWAITING_REVIEW</code> in it. When <code>verify</code> fails, that just gets recorded. The queue drains. The loop comes back around, finds the queue empty, and asks the planner what to do about a state that now contains a failed verification. The planner might retry it. It might decompose it into three smaller tasks. It might notice the spec was wrong, which is the thing my DAG couldn&#8217;t do at any price.</p><p>The retry loop is emergent, not coded. That&#8217;s the self-driving property, and it costs exactly one <code>if not queue:</code>.</p><p>I find this genuinely hard to hold onto. Every time I read this loop I want to add an edge. Some case comes to mind, the reviewer rejects on style versus correctness and surely those should route differently, and my hand goes to the keyboard. Don&#8217;t. That&#8217;s a decision, decisions are the planner&#8217;s job, and the planner has strictly more information at tick 12 than you have right now.</p><h2>State is the architecture</h2><p>Two properties, and both of them are load-bearing.</p><p><strong>Append-only, not a mutable blackboard.</strong> The planner&#8217;s next decision is a function of history. Overwriting is amnesia. If tick 4 recorded &#8220;tried the obvious fix, test still red&#8221; and tick 9 overwrites it, the planner at tick 10 will happily try the obvious fix. You also get replay for free, which matters the first time one of them does something whack at 3am and you need to know whether the planner was wrong or the state was.</p><p><code>context_for(task)</code><strong> is a projection, not a dump.</strong> Each worker sees what its job needs. The executor gets the spec, the file paths, the test command. It does not get the planner&#8217;s reasoning, the reviewer&#8217;s prior judgments, or the transcripts of its five siblings.</p><p>This is where most implementations die, and it dies in disguise. You dump full state into every call because it&#8217;s easy and it&#8217;s correct-<em>ish</em>, and by tick four you&#8217;re feeding the executor a novel. Quality degrades. It feels like a model problem. You go swap models. It&#8217;s not a model problem, it&#8217;s that you handed a worker fifty thousand tokens of other people&#8217;s thinking and asked it to find the one line that was actually its assignment.</p><h2>Stopping</h2><p>Three mechanisms. You need all of them, and they fail differently.</p><p><code>goal_satisfied</code> is a predicate over recorded verifications. Never a model announcing success. Same reasoning as the verifier, higher stakes, because this one&#8217;s the exit.</p><p>Budget exhaustion raises, and it raises with a state summary, not just a log line. Something a human can read to find out what happened.</p><p>No-progress detection: if the state hash hasn&#8217;t moved in N ticks, kill it and escalate. This is the one people skip and it&#8217;s the one that saves you money, because a planner stuck in a loop of semantically-identical tasks will happily burn every token you&#8217;ll give it while producing state that&#8217;s byte-identical tick over tick.</p><p>The principle underneath all three: an agent that can&#8217;t finish has to say so <strong>loudly</strong>. Silent success is the worst failure mode you can ship, because it&#8217;s the one that ships. A crash gets fixed on Tuesday. A green checkmark on a job that quietly did nothing gets found in November.</p><h2>Watching it work: incident to patch</h2><p>Alert fires. Goal: figure out what borked and fix it.</p><p><strong>Tick 1.</strong> Queue&#8217;s empty, so the planner runs. It reads the alert, the logs, and a metrics window, and emits one task: write a failing test that demonstrates the reported behavior. Not &#8220;fix the bug.&#8221; The planner doesn&#8217;t know what the bug is yet and it isn&#8217;t pretending to.</p><p><strong>Tick 2.</strong> Executor takes it. Reads the code, writes a test, runs it. Artifact: a diff containing one new test. Verifier is a shell command: does the suite fail, and does it fail in the new test rather than somewhere else? Say it passes instead, meaning the test went green immediately. Recorded as a failed verification. Not an error. A fact.</p><p><strong>Tick 3.</strong> Queue&#8217;s empty again, planner runs again, and now it&#8217;s reading a state that says &#8220;our first theory of the bug produced a test that passes.&#8221; That&#8217;s information. It emits a different task: check whether the alert is even real, or whether the metric threshold moved.</p><p>The DAG I would have drawn cannot do tick 3. It would have taken the passing test as a green light and gone on to patch a bug that wasn&#8217;t there.</p><p>Note the gate sitting between the repro executor and the patch executor. Two executors back to back with nothing in between compounds error: the second one builds on the first one&#8217;s work with no independent check that the first one&#8217;s work was real. The verifier isn&#8217;t ceremony, it&#8217;s the thing that keeps the second executor from inheriting a hallucination as a premise.</p><p>Run the same alert twice and you&#8217;ll get different task sequences, because the planner sees different state. That&#8217;s the payoff and it&#8217;s also the part that makes these systems annoying to test, which is a real cost and I&#8217;m not going to pretend otherwise.</p><h2>When you shouldn&#8217;t build this</h2><p>Most of the time. Despite the echo chamber of modern .. life.</p><p>If the task shape is known, the steps are fixed, and failure means stop, just write the damn script. Tell your director to shove it and punch them in the face. It&#8217;ll be faster, it&#8217;ll cost less, you can reason about it, and when it breaks you&#8217;ll know why in about ninety seconds. Nobody has ever been paged at 3am because their bash script had insufficient agency, or it hallucinated a <code>glob</code> as <code>rm -rf /</code> in a privileged environment.</p><p><strong>The loop earns its complexity in exactly one condition</strong>: <em>the task space is open and partial failure is expected and informative.</em> Open means you cannot enumerate the paths ahead of time. Informative means a failure at tick 2 should change what happens at tick 3. If failures don&#8217;t change the plan, you don&#8217;t need a planner, you need a retry decorator and a good night&#8217;s sleep.</p><p>Most things sold as agents are scripts wearing a hat. That&#8217;s frequently correct. The hat is the problem, not the script.</p><h2>The tell</h2><p>Here&#8217;s how I know now, before I write anything: I ask what happens when the plan is wrong.</p><p>If the answer is &#8220;it fails and I look at it,&#8221; write the script. Genuinely. That&#8217;s a fine answer and it&#8217;s the right answer more often than the discourse suggests.</p><p>If the answer is &#8220;it needs to notice and try something else,&#8221; you don&#8217;t want a better DAG. You want a loop with a planner in it, and you want the courage to leave the edges out, which is harder than it sounds because the edges feel like engineering and the empty space feels like you forgot something.</p><p>You didn&#8217;t. That&#8217;s the design.</p>]]></content:encoded></item></channel></rss>