<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Agents on Matt Goodrich</title><link>https://mattgoodrich.com/tags/agents/</link><description>Recent content in Agents on Matt Goodrich</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Tue, 21 Jul 2026 05:11:49 -0700</lastBuildDate><atom:link href="https://mattgoodrich.com/tags/agents/index.xml" rel="self" type="application/rss+xml"/><item><title>Letting the Loop Merge: The Janitors That Keep It Honest</title><link>https://mattgoodrich.com/posts/letting-the-loop-merge/</link><pubDate>Tue, 21 Jul 2026 05:11:49 -0700</pubDate><guid>https://mattgoodrich.com/posts/letting-the-loop-merge/</guid><description>&lt;img src="https://mattgoodrich.com/posts/letting-the-loop-merge/header.png" alt="Featured image of post Letting the Loop Merge: The Janitors That Keep It Honest" />&lt;p>&lt;strong>Every recurring failure in the loop ends up as one of two things: a line in a policy file, or a small script that cleans up after it.&lt;/strong> I ended &lt;a class="link" href="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/" >the last post&lt;/a> by calling the loop a solved problem, with the only hard part left being the cut: deciding what goes into an issue. That was true when I wrote it, and it still is. What it skipped is the part that got interesting: what it took to let the agents merge and deploy without me in the loop.&lt;/p>
&lt;p>The trust I described was a feeling. Watching the runners be right, over and over, is what produced it. Since then I have done the unromantic thing and turned that feeling into architecture. There is now a written policy for how far the agents act alone, and a set of small janitors that keep an autonomous queue from rotting while I am not looking. The support cast in that last post was three scripts in a table. It is closer to eight now, and it is the part of the system I actually think about.&lt;/p>
&lt;h2 id="the-trust-became-a-policy">The Trust Became a Policy
&lt;/h2>&lt;p>The rule is one sentence. Autonomous by default. Escalate only when risky.&lt;/p>
&lt;p>It lives in a single file that every runner prompt and skill points at, so it cannot drift into five slightly different versions. For any task that is not flagged risky, the agent runs the whole loop and closes the issue itself: implement, run tests, review its own diff, commit, push the branch, open a PR, merge to main, post the receipt. No human in the middle. The gate for that auto-merge is narrow and mechanical: tests pass, the self-review came back approved, the diff is inside a safe size, and the spec is clear and self-consistent. Miss any one of those and it stops.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/letting-the-loop-merge/diagram-auto-merge-gate.png"
width="491"
height="973"
srcset="https://mattgoodrich.com/posts/letting-the-loop-merge/diagram-auto-merge-gate_hu6185326898512113103.png 480w, https://mattgoodrich.com/posts/letting-the-loop-merge/diagram-auto-merge-gate_hu10493156407934196560.png 1024w"
loading="lazy"
alt="The auto-merge gate: a new task is first checked for risk. A risky task escalates to Agent Needs Input; a non-risky one runs the whole loop (implement, test, self-review, commit, push, open a PR) and reaches a four-part gate. Tests pass, self-review approved, diff in safe size, and spec clear merges to main and posts the receipt; any failure escalates to me."
class="gallery-image"
data-flex-grow="50"
data-flex-basis="121px"
>&lt;/p>
&lt;p>The important half of the policy is the definition of risky, because that is the only thing that reaches me. Ten categories: production effects, destructive or irreversible actions, security-sensitive code, changes to the engine&amp;rsquo;s own guardrails, data migrations, ambiguous specs, a real verification gap, an explicit human-action label, cross-repo blast radius, and cost above a threshold. Everything else the loop owns end to end.&lt;/p>
&lt;p>The line I come back to most is the one about doubt. &amp;ldquo;I wasn&amp;rsquo;t sure&amp;rdquo; is not a reason to escalate. &amp;ldquo;This could do real, hard-to-undo harm and I can&amp;rsquo;t verify it&amp;rsquo;s safe&amp;rdquo; is. Uncertainty is the runner&amp;rsquo;s normal working condition. If mild uncertainty routed to me, I would be the queue again, which is exactly the cost I built the thing to remove.&lt;/p>
&lt;h2 id="the-engine-stops-at-merge">The Engine Stops at Merge
&lt;/h2>&lt;p>The sharpest change since the last post is a distinction I did not have language for then. Merging to main is autonomous. Deployment is not the engine&amp;rsquo;s concern at all.&lt;/p>
&lt;p>I learned this by getting it wrong. The original pipeline treated a deploy as the third issue in every feature, and it gated the merge on the deploy being safe. The result was that every finished piece of product work stacked up waiting for a deploy decision. My &amp;ldquo;Agent Needs Input&amp;rdquo; column turned into a merge queue, 32 items deep, all of them done and none of them landed. The gate was protecting me from a risk that did not live where I had put the gate.&lt;/p>
&lt;p>So I moved it. The engine&amp;rsquo;s job now ends at merge on green: push, PR, gates, merge, receipt, regardless of what the repo&amp;rsquo;s deploy config says. Whether that merge then reaches production is owned by something else. On my Unraid box that is Watchtower, and the real deploy toggle is a per-container label I flip by hand, outside the loop. The runners still read the deploy mode and write it into the record, but they never hold a merge for it and never deploy anything themselves.&lt;/p>
&lt;p>The same move retired an old boundary I used to paste into issue bodies out of caution: no pushing to the remote, no opening PRs. That rule had once stranded 17 finished items that were not allowed to do the last step. It is gone now. Pushing and opening a PR are normal steps. Boundaries go into an issue only for the genuinely risky categories, not as a reflex.&lt;/p>
&lt;h2 id="a-janitor-for-every-rot">A Janitor for Every Rot
&lt;/h2>&lt;p>Once the loop merges on its own, a new failure mode appears: things rot quietly. An autonomous queue does not tell you when it is stuck, because the whole point is that nobody is watching it. So the support cast grew, and every member of it exists because one specific thing rotted once.&lt;/p>
&lt;p>A PR sat behind main long enough that main moved and the PR could no longer merge cleanly. Now a cron proactively merges main into open PR heads before a review even picks them up.&lt;/p>
&lt;p>The base-drift reconciler is representative of the set. It only touches a PR whose base is &lt;code>main&lt;/code> and that GitHub already reports as drifted, and when it merges &lt;code>main&lt;/code> forward it pushes the result without ever forcing:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># only main-based PRs that GitHub already flags as drifted&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">should_reconcile&lt;span class="o">()&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">local&lt;/span> &lt;span class="nv">base&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$1&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="nv">mss&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$2&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">[&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$base&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;main&amp;#34;&lt;/span> &lt;span class="o">]&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">[&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$mss&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DIRTY&amp;#34;&lt;/span> &lt;span class="o">]&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="o">[&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$mss&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;CONFLICTING&amp;#34;&lt;/span> &lt;span class="o">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="o">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># in a throwaway worktree cut from origin/main, merge main forward:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">reconcile_pr_from_base &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$wt&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$base&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$head&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">case&lt;/span> &lt;span class="nv">$?&lt;/span> in
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 0&lt;span class="o">)&lt;/span> git -C &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$wt&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> push origin &lt;span class="s2">&amp;#34;HEAD:refs/heads/&lt;/span>&lt;span class="nv">$head&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">;;&lt;/span> &lt;span class="c1"># clean: push, never --force&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 1&lt;span class="o">)&lt;/span> log &lt;span class="s2">&amp;#34;PR-&lt;/span>&lt;span class="nv">$number&lt;/span>&lt;span class="s2"> -&amp;gt; CONFLICT, leave it for a human&amp;#34;&lt;/span> &lt;span class="p">;;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> *&lt;span class="o">)&lt;/span> log &lt;span class="s2">&amp;#34;PR-&lt;/span>&lt;span class="nv">$number&lt;/span>&lt;span class="s2"> -&amp;gt; FATAL&amp;#34;&lt;/span> &lt;span class="p">;;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">esac&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A mergeable PR got orphaned. Its issue closed through a path that forgot to file the review task, so the PR just sat there, green and ignored, for days. That actually happened to a Tend PR that was ready to merge for the better part of a week before I hand-filed the missing issue. Now a standing scan catches any PR in that state and files the review for it.&lt;/p>
&lt;p>A PR stayed open too long with nobody noticing. Now an escalator posts one follow-up note on its review issue after a few hours, so age becomes visible instead of silent.&lt;/p>
&lt;p>An agent marked an issue Done that never actually integrated. The runner returned success on exit code 0 without the work landing on main. There is a gate at the Done transition that prevents new ones now, and a backstop cron that reopens any that slip through, checking that the receipt&amp;rsquo;s PR really is merged.&lt;/p>
&lt;p>An issue looked like it needed a decision when its work had already shipped through a sibling PR on main. A surfacer flags those as candidates to cancel, so the &amp;ldquo;needs input&amp;rdquo; pile stays honest about what is genuinely open.&lt;/p>
&lt;p>And underneath all of it, a digest posts everything that actually needs me to a Discord channel, so a human follow-up cannot get lost between the cracks of two runtimes and a tracker.&lt;/p>
&lt;p>None of these are clever. Each is a couple hundred lines of shell on a timer, and I added every one of them after something rotted while I was not looking. The &lt;a class="link" href="https://github.com/mgoodric/plane-client/tree/main/docs/open-engine/janitors" target="_blank" rel="noopener"
>full set is in the repo&lt;/a>, redacted the same way the autonomy policy is.&lt;/p>
&lt;h2 id="the-bias-toward-surfacing">The Bias Toward Surfacing
&lt;/h2>&lt;p>Here is the argument against everything above. A mesh of janitors is standing surface area. Each one is code I now maintain, and a bad janitor is worse than none, because it corrupts the thing it was supposed to protect while I am not watching. An auto-canceller that is wrong deletes real work. A reconciler that force-pushes to fix drift can destroy a branch.&lt;/p>
&lt;p>That risk shaped how the janitors are allowed to act. The one that spots superseded issues does not cancel them; it surfaces them for me to cancel, because a wrong cancel loses work and a wrong surface costs a glance. The one that catches false-Dones does not silently redo the work; it reopens the issue to review. The base-drift reconciler merges forward but never force-pushes. The bias is to make rot visible and leave the irreversible call to a person.&lt;/p>
&lt;p>Some failures do not deserve a janitor at all. They deserve a sentence. One stale test that had been red on main for weeks was parking every unrelated issue that happened to run the same suite, each agent correctly refusing to proceed with a failing test, each one wrong about which failure mattered. The fix was a ruling: a test already failing on main, that your change does not touch, is not your blocker. Establish the baseline, prove you added no new failures, name the old one in your receipt, move on. Pass-count parity with main is the bar.&lt;/p>
&lt;p>There is a third answer I did not expect. Sometimes a janitor is quietly telling you the source is broken. My base-drift reconciler started failing on PRs it should have fixed, two in a row, because the drift had a different cause: the branches had been cut from a stale checkout and carried a dozen already-merged commits, so there was nothing clean to rebase. The fix is one line in the runner: cut new branches from the current main, not from whatever the working tree happens to point at. When a janitor keeps failing the same way, the bug is usually upstream of the janitor.&lt;/p>
&lt;h2 id="policy-line-or-janitor">Policy Line or Janitor
&lt;/h2>&lt;p>That is the shape of the work now. A failure shows up. I decide whether it wants a policy line or a janitor. A recurring class of bad judgment becomes a line in the policy file. A recurring class of silent rot becomes a small script on a timer. Most things are one or the other, and knowing which is most of the skill. Once in a while it is neither, and a janitor that keeps failing is pointing at a bug I should have fixed at the source.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/letting-the-loop-merge/diagram-policy-or-janitor.png"
width="754"
height="432"
srcset="https://mattgoodrich.com/posts/letting-the-loop-merge/diagram-policy-or-janitor_hu15498260681156148329.png 480w, https://mattgoodrich.com/posts/letting-the-loop-merge/diagram-policy-or-janitor_hu17289485313323724121.png 1024w"
loading="lazy"
alt="How each failure gets handled: a failure that is recurring bad judgment becomes a line in the policy file; recurring silent rot becomes a surface-biased janitor on a timer; and a janitor that keeps failing points to a bug at the source."
class="gallery-image"
data-flex-grow="174"
data-flex-basis="418px"
>&lt;/p>
&lt;p>The last post said the bottleneck had moved from running the loop to cutting the work. It moved again. I do not spend much time on the cut anymore either; the breakdown discipline is mostly muscle memory now. What I spend time on is the second-order question. The loop merges on its own. The mesh keeps it honest. My job is deciding which of the failures I see this week is worth a new janitor, and which is really a missing sentence in a policy I already have.&lt;/p>
&lt;p>I used to maintain a queue. Then I decided what went in it. Now I decide how the thing that empties it is allowed to fail. Each of those is a stranger problem than the one before it.&lt;/p></description></item><item><title>Two Systems for Handing Work to Agents</title><link>https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/</link><pubDate>Mon, 20 Jul 2026 17:50:01 -0700</pubDate><guid>https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/</guid><description>&lt;img src="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/header.png" alt="Featured image of post Two Systems for Handing Work to Agents" />&lt;p>&lt;strong>Where the queue lives decides what the work costs.&lt;/strong> I have built two systems for handing work to AI agents. In the first, the queue lived inside the repository as a markdown file. In the second, it lives in a self-hosted issue tracker that two agent runtimes poll every twelve minutes. The second one is better, and the reason is bookkeeping.&lt;/p>
&lt;h2 id="639-slices-and-a-status-file">639 Slices and a Status File
&lt;/h2>&lt;p>&lt;a class="link" href="https://github.com/mgoodric/security-atlas" target="_blank" rel="noopener"
>security-atlas&lt;/a> is a security program in a box, aimed at early programs that are standing everything up from scratch. I have been building it for the better part of a year, and almost none of it was typed by me.&lt;/p>
&lt;p>The unit of work is a &lt;strong>slice&lt;/strong>: one tracer-bullet vertical change, specified in a single markdown file at &lt;code>docs/issues/NNN-slug.md&lt;/code> with acceptance criteria, a threat model, an explicit anti-criteria list of what it will not do, and dependencies on other slices. A local skill, &lt;code>idea-to-slice&lt;/code>, turns an idea into one of those documents. It reads the constitutional invariants in &lt;code>CLAUDE.md&lt;/code>, the three most recent slices for current convention, the design canvas, and the recent migrations, then grills the idea against the domain model before it writes anything.&lt;/p>
&lt;p>A second prompt, &lt;code>Plans/prompts/05-parallel-batch.md&lt;/code>, is the orchestration unit: pick up to three conflict-safe slices from the ready set, build each in its own git worktree in parallel, squash-merge each to &lt;code>main&lt;/code>. A third file, &lt;code>07-continuous-batch-loop.md&lt;/code>, wraps that in Claude Code&amp;rsquo;s &lt;code>/loop&lt;/code> in dynamic mode with four guards, so batches keep running unattended until the ready set empties or a guard fires. It is stateless across invocations by design. Restarting it is just running &lt;code>/loop&lt;/code> again, because all the state lives in git.&lt;/p>
&lt;p>The agents ran on the Mac Studio, in the &lt;a class="link" href="https://mattgoodrich.com/posts/my-ai-workspace-tmux-cloudflared/" >tmux workspace I wrote about&lt;/a>, which means I could check on a batch from a laptop anywhere.&lt;/p>
&lt;p>It worked. 1,440 commits. 1,446 pull requests. 639 slice documents filed, 564 of them merged as of the last snapshot. I would file a batch of slices in the evening and read diffs in the morning. Work genuinely flowed through the system.&lt;/p>
&lt;p>The problem was what each piece of work had to do on its way through.&lt;/p>
&lt;h2 id="the-bookkeeping-tax">The Bookkeeping Tax
&lt;/h2>&lt;p>&lt;code>docs/issues/_STATUS.md&lt;/code> was the queue and the ledger at the same time. It listed every slice and its state: ready, not-ready, in-progress, in-review, merged. To claim a slice, an agent edited that file. To finish one, it edited that file again. Both edits were commits, and because &lt;code>main&lt;/code> was protected, both commits were pull requests.&lt;/p>
&lt;p>So a slice worth one commit of real code cost three PRs: claim the slot, merge the work, reconcile the status. Multiply that by three slices running in parallel and the file becomes the contention point. Two agents claiming slices at the same moment touch the same lines of the same file, and one of them rebases. A measurable share of the system&amp;rsquo;s throughput went into writing down what the system was doing.&lt;/p>
&lt;p>I called it a status file. It behaved like a lock.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-bookkeeping-tax.png"
width="1238"
height="153"
srcset="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-bookkeeping-tax_hu817668749421368353.png 480w, https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-bookkeeping-tax_hu3205960651104366154.png 1024w"
loading="lazy"
alt="One slice worth a single commit of real code spawns three pull requests in sequence — claim the slot by editing the status file, merge the actual change, then reconcile the status file again — because a protected main turns every edit into a pull request."
class="gallery-image"
data-flex-grow="809"
data-flex-basis="1941px"
>&lt;/p>
&lt;p>Slice 741 killed it. &lt;code>_STATUS.md&lt;/code> became a generated artifact, projected from git history plus an append-only event log at &lt;code>docs/issues/_events.jsonl&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">just ready &lt;span class="c1"># filed, unmerged, not in-flight, unblocked&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">just status &lt;span class="c1"># regenerate _STATUS.md from git + events&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">just event &lt;span class="m">742&lt;/span> not-ready &lt;span class="s2">&amp;#34;dep 700&amp;#34;&lt;/span> &lt;span class="c1"># append an event, don&amp;#39;t edit the file&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Pushing a &lt;code>feat/NNN-…&lt;/code> branch claims a slice. Merging its PR completes it. Neither fact needs to be written down anywhere, because both are already in git. The &lt;code>chore(status)&lt;/code> pull requests went away.&lt;/p>
&lt;p>Even that fix has a rough edge worth naming. The plan was for CI to regenerate &lt;code>_STATUS.md&lt;/code> and push it on every merge to &lt;code>main&lt;/code>. A GitHub Actions &lt;code>GITHUB_TOKEN&lt;/code> cannot push to a protected &lt;code>main&lt;/code> on a personal repo, so the committed copy is regenerated on demand and allowed to lag. It is non-gating now, which is survivable precisely because git is the truth and the file is only a view of it.&lt;/p>
&lt;p>The fix was right and it taught me something I did not want to hear. Deriving the ledger instead of authoring it is a workaround for the real issue: the queue was in the repository, and a repository is a bad place to keep a queue. Every state transition costs a commit. Every commit costs a review. Git is built for durable, reviewable, permanent change, which is the exact opposite of what a mutable to-do list needs.&lt;/p>
&lt;h2 id="plane-instead-of-linear">Plane Instead of Linear
&lt;/h2>&lt;p>I got the pattern from &lt;a class="link" href="https://unlock-ai.natebjones.com/open-engine" target="_blank" rel="noopener"
>Nate B. Jones&lt;/a>. His Open Engine puts the queue in Linear. Agent runtimes poll an issue tracker, claim an issue, do the work, and post receipts back as comments. The tracker becomes the coordination substrate and the repository goes back to being only a repository.&lt;/p>
&lt;p>I did not want another paid SaaS subscription. &lt;a class="link" href="https://plane.so" target="_blank" rel="noopener"
>Plane&lt;/a> is a self-hostable tracker with a REST API, so mine runs in a container on the Unraid box under a local DNS name, reachable &lt;a class="link" href="https://mattgoodrich.com/posts/collapsing-a-pile-of-tunnels-onto-tailscale/" >over the tailnet&lt;/a> and costing nothing. Everything else in Nate&amp;rsquo;s pattern survives the swap.&lt;/p>
&lt;h2 id="two-runtimes-twelve-minutes-one-task-per-fire">Two Runtimes, Twelve Minutes, One Task Per Fire
&lt;/h2>&lt;p>The engine is a Plane project called OPENENGINE running a seven-state machine. The states the runners key on:&lt;/p>
&lt;pre tabindex="0">&lt;code>Agent Todo → Agent Working → Agent Needs Input | Agent Review → Agent Done
&lt;/code>&lt;/pre>&lt;p>&lt;img src="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-state-machine.png"
width="888"
height="180"
srcset="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-state-machine_hu12637992233095645850.png 480w, https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-state-machine_hu4302377953715523148.png 1024w"
loading="lazy"
alt="The state flow the runners key on: Agent Todo advances to Agent Working, which either proceeds through Agent Review to Agent Done, or — when the work is risky or the spec is unclear — branches to Agent Needs Input and returns to Agent Working once a human answers."
class="gallery-image"
data-flex-grow="493"
data-flex-basis="1184px"
>&lt;/p>
&lt;p>An issue is addressed to a runtime by a bracket in its title and routed by an &lt;code>agent-instructions&lt;/code> label. Two runtimes poll for it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>matt-claude&lt;/code>&lt;/strong> — Claude Code, every 12 minutes&lt;/li>
&lt;li>&lt;strong>&lt;code>matt-codex&lt;/code>&lt;/strong> — OpenAI Codex, every 12 minutes&lt;/li>
&lt;/ul>
&lt;p>Both are macOS launchd jobs running shell scripts that shell out to &lt;code>claude --print&lt;/code> or &lt;code>codex exec&lt;/code> with an XML prompt. The prompt is the loop, and it is explicit to the point of tedium, because a scheduled fire is a fresh session with no inherited context:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-xml" data-lang="xml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;order&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;step&amp;gt;&lt;/span>This runtime&amp;#39;s agent code is `matt-claude`.&lt;span class="nt">&amp;lt;/step&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;step&amp;gt;&lt;/span>Open the status ledger issue and find this agent&amp;#39;s AGENT STATUS
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> comment. Its id is b54e11dd-…. NEVER POST a new comment — always
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> PATCH this exact id.&lt;span class="nt">&amp;lt;/step&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> …
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;/order&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The rule that makes the whole thing tractable is Nate&amp;rsquo;s: &lt;strong>one task per fire.&lt;/strong> A runner claims exactly one Open Engine issue (OE) per tick, works it, posts a receipt, and exits. It never tries to drain the queue. Each fire gets its own git worktree, so two runtimes working two issues never see each other&amp;rsquo;s files.&lt;/p>
&lt;p>The support cast turned out to matter more than I expected:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Script&lt;/th>
&lt;th>Cadence&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Why it exists&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>heartbeat-watchdog.sh&lt;/code>&lt;/td>
&lt;td>15 min&lt;/td>
&lt;td>macOS alert when a runner&amp;rsquo;s heartbeat goes stale past 30 minutes&lt;/td>
&lt;td>A launchd job can stop firing silently — a crash, a hung &lt;code>claude&lt;/code> call, an API outage — and nothing else tells you the queue quit draining. This is the only thing that notices the loop is dead.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pr-merge-reconciler.sh&lt;/code>&lt;/td>
&lt;td>15 min&lt;/td>
&lt;td>Advances an OE to &lt;code>Agent Done&lt;/code> when its PR merged outside the loop, flags closed-unmerged PRs, never touches &lt;code>Agent Working&lt;/code>&lt;/td>
&lt;td>PRs get merged by hand outside the loop, which leaves the ledger claiming work is still in flight that git already finished. Without reconciliation those OEs sit in limbo and the board stops reflecting reality.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>refresh-pat.sh&lt;/code>&lt;/td>
&lt;td>manual&lt;/td>
&lt;td>Re-caches the Plane PAT out of 1Password to &lt;code>~/.config/openengine/plane-pat&lt;/code> at mode &lt;code>0600&lt;/code>&lt;/td>
&lt;td>The token rotates, and when it does every runner fails auth on its next fire. Sourcing it from 1Password keeps the secret out of the scripts and turns a rotation into one command instead of an edit across every job.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="three-issues-per-feature">Three Issues Per Feature
&lt;/h2>&lt;p>A feature that actually ships lands as three OEs, chained parent to child:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Runtime&lt;/th>
&lt;th>Output&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Implementation&lt;/td>
&lt;td>&lt;code>matt-codex&lt;/code>&lt;/td>
&lt;td>Code in a worktree, an open PR, and a child OE for review&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Review and merge&lt;/td>
&lt;td>&lt;code>matt-claude&lt;/code>&lt;/td>
&lt;td>Merged PR, deleted branch, removed worktree, and a child OE for deploy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Deploy&lt;/td>
&lt;td>&lt;code>matt-claude&lt;/code>&lt;/td>
&lt;td>New container running, &lt;code>/health&lt;/code> returning 200&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-feature-chain.png"
width="938"
height="166"
srcset="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-feature-chain_hu14018779869780498739.png 480w, https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-feature-chain_hu839839120501235636.png 1024w"
loading="lazy"
alt="Three stages chained parent to child: Implementation on matt-codex produces code in a worktree and an open PR, which files a child OE for Review and merge on matt-claude, which files a child OE for Deploy on matt-claude, ending with a running container whose health check returns 200."
class="gallery-image"
data-flex-grow="565"
data-flex-basis="1356px"
>&lt;/p>
&lt;p>The runtime split is convention, not enforcement. Codex is better at hands-on file editing inside a worktree with broad system access. Claude is better at reading a diff, running the tests, and orchestrating a multi-step deploy with careful boundary checks. Either runtime could do either stage. Keeping the convention means each queue stays unambiguous and I can tell at a glance who is holding what.&lt;/p>
&lt;p>Three stages is three fires and three &lt;code>AGENT DONE&lt;/code> receipts. The first product to go the whole way through was my notification hub — a small service that takes events from my agents and scripts and turns them into spoken and pushed notifications. Codex implemented it, Claude reviewed and merged it, and it deployed to Unraid as a container with a passing health check.&lt;/p>
&lt;h2 id="the-cli-the-mcp-and-the-sharp-edges-of-the-plane-api">The CLI, the MCP, and the Sharp Edges of the Plane API
&lt;/h2>&lt;p>Talking to Plane from a shell script with &lt;code>curl&lt;/code> cost me more time than any other part of this, and none of it for interesting reasons. The API has a set of traps:&lt;/p>
&lt;ul>
&lt;li>Control characters arrive unescaped inside JSON responses, so &lt;code>jq&lt;/code> chokes on a valid-looking payload&lt;/li>
&lt;li>The pagination field that reports whether there is a next page will lie to you&lt;/li>
&lt;li>Server-side filters are accepted and then silently ignored, so you get everything back and think you filtered&lt;/li>
&lt;li>An apostrophe in an issue title returns a 400&lt;/li>
&lt;li>There is a per-token rate limit that nothing documents&lt;/li>
&lt;/ul>
&lt;p>I encoded those workarounds once, in &lt;a class="link" href="https://github.com/mgoodric/plane-client" target="_blank" rel="noopener"
>&lt;code>plane-client&lt;/code>&lt;/a>: a Python client, a CLI, and an MCP server. Standard library only, no &lt;code>requests&lt;/code>, nothing to audit but the code in the repo. The MCP server is what lets any Claude session read and write the engine directly, with &lt;code>plane_list&lt;/code>, &lt;code>plane_get&lt;/code>, &lt;code>plane_create&lt;/code>, &lt;code>plane_comment&lt;/code>, and &lt;code>plane_set_state&lt;/code>, instead of shelling out to &lt;code>curl&lt;/code> and rediscovering the traps. It is public because the sharp edges are not specific to me.&lt;/p>
&lt;p>Two Claude Code skills sit on top of it. &lt;a class="link" href="https://github.com/mgoodric/plane-client/blob/main/docs/open-engine/to-engine.md" target="_blank" rel="noopener"
>&lt;code>to-engine&lt;/code>&lt;/a> files a single OE with the right title bracket, the &lt;code>agent-instructions&lt;/code> label, and the nine-section body the runners know how to parse. &lt;a class="link" href="https://github.com/mgoodric/plane-client/blob/main/docs/open-engine/break-to-engine.md" target="_blank" rel="noopener"
>&lt;code>break-to-engine&lt;/code>&lt;/a> takes a feature or a spec and cuts it into a set of OEs the loop can actually ship. Both are published, lightly redacted, alongside the &lt;code>plane-client&lt;/code> code.&lt;/p>
&lt;p>That second skill exists because of one specific bad week.&lt;/p>
&lt;h2 id="six-issues-that-did-not-cohere">Six Issues That Did Not Cohere
&lt;/h2>&lt;p>On 3 July I broke four features into OEs and let the loop run: Meross energy monitoring (whole-panel power data from a monitor in my breaker box, aggregated into Grafana), a web front end for the notification hub, Plaid onboarding (bank-account linking for Tend, my home-management tool meant to replace Quicken and YNAB), and voice fan-out (ElevenLabs speaking notifications aloud on my machines to pull my attention back to a waiting Claude session). I sliced them the way you would break down a sprint. One issue per file. One issue per layer.&lt;/p>
&lt;p>Meross became six issues. A migration. A config module. A collector. A schedule. A health check. A dashboard.&lt;/p>
&lt;p>Every one of them completed. Every one reported implementation complete. The feature was non-functional at the first database write, because &lt;strong>the migration OE and the collector OE had diverged on column names.&lt;/strong> No per-slice test caught it and no per-slice test could have, because the contract was split across two issues and each side was internally consistent with itself.&lt;/p>
&lt;p>That was the cheapest of the four failures.&lt;/p>
&lt;ul>
&lt;li>Slices that depended on each other said so in prose (&amp;ldquo;requeue after OE-183&amp;rdquo;) instead of in Plane&amp;rsquo;s &lt;code>parent&lt;/code> field, so the runner&amp;rsquo;s dependency-gating never saw the edge and the issues piled up in &lt;code>Agent Needs Input&lt;/code> waiting for a human to notice.&lt;/li>
&lt;li>One OE was sized too large, its fire timed out, and it fail-looped. That is OE-200, and it is why there is now a circuit breaker.&lt;/li>
&lt;li>A boilerplate boundary I had been pasting into every issue, &amp;ldquo;do not push, do not open a PR,&amp;rdquo; stranded &lt;strong>17 finished items&lt;/strong> in &lt;code>Agent Needs Input&lt;/code> with their work done and nowhere to go.&lt;/li>
&lt;li>One epic left a 47-commit runaway branch behind.&lt;/li>
&lt;/ul>
&lt;p>The loop executed correctly through all of it. The runners claimed, worked, and reported exactly as designed. The breakdown was what failed, and nothing in the system could have told me so.&lt;/p>
&lt;h2 id="the-five-rules">The Five Rules
&lt;/h2>&lt;p>&lt;code>break-to-engine&lt;/code> is those failures turned into rules.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Vertical slices, not horizontal steps.&lt;/strong> A slice is one independently shippable, end-to-end-verifiable change. If it cannot merge and be verified on its own, it is not a slice. Fold it into the one it completes.&lt;/li>
&lt;li>&lt;strong>Keep a contract inside one slice.&lt;/strong> A schema and the code that writes it. An API and its first caller. A config field and the code that reads it. Never across two OEs. This is the load-bearing rule and it is the one Meross broke.&lt;/li>
&lt;li>&lt;strong>Right-size for a single fire.&lt;/strong> Roughly 20 minutes of walltime. Too big and it times out. Too small and it floods the queue and multiplies integration seams.&lt;/li>
&lt;li>&lt;strong>Structure the dependencies, do not narrate them.&lt;/strong> If B needs A merged first, set B&amp;rsquo;s &lt;code>parent&lt;/code> to A&amp;rsquo;s UUID. The runner gates on the field. It cannot gate on a sentence.&lt;/li>
&lt;li>&lt;strong>Risk-scoped boundaries, integration-test acceptance.&lt;/strong> Boundaries come from a risk tier in &lt;a class="link" href="https://github.com/mgoodric/plane-client/blob/main/docs/open-engine/AUTONOMY.md" target="_blank" rel="noopener"
>&lt;code>docs/AUTONOMY.md&lt;/code>&lt;/a>, never a blanket prohibition. Acceptance criteria require end-to-end verification against the real interface, because the mocked-database unit test is exactly what let the schema break through.&lt;/li>
&lt;/ol>
&lt;p>The sizing heuristic that does the most work: if you are writing more than about six OEs for one feature, you are slicing horizontally. Stop and re-cut.&lt;/p>
&lt;h2 id="what-each-system-is-actually-good-at">What Each System Is Actually Good At
&lt;/h2>&lt;p>I did not throw the slice system away, and I am not going to.&lt;/p>
&lt;p>&lt;img src="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-two-systems.png"
width="1006"
height="628"
srcset="https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-two-systems_hu12238422210981716183.png 480w, https://mattgoodrich.com/posts/two-systems-for-handing-work-to-agents/diagram-two-systems_hu11857030624826030362.png 1024w"
loading="lazy"
alt="Side by side contrast of the two systems: on the left, the queue in the repo — a slice doc feeds git history and an event log, which costs pull requests to claim, merge, and reconcile, run through /loop up to three slices in parallel; on the right, the queue in the tracker — Plane issues changed with one API call, polled by matt-claude and matt-codex, each doing one task per fire in its own worktree."
class="gallery-image"
data-flex-grow="160"
data-flex-basis="384px"
>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Slices and &lt;code>/loop&lt;/code>&lt;/th>
&lt;th>Open Engine&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Where the queue lives&lt;/td>
&lt;td>In the repo: &lt;code>docs/issues/&lt;/code>, git, events&lt;/td>
&lt;td>Outside it, in Plane&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scope&lt;/td>
&lt;td>One repository&lt;/td>
&lt;td>Any repo, any machine, plus ops and deploys&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost of a state change&lt;/td>
&lt;td>A commit, sometimes a PR&lt;/td>
&lt;td>One API call&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Parallelism&lt;/td>
&lt;td>Up to 3 conflict-safe slices per batch, in worktrees&lt;/td>
&lt;td>One task per fire, per runtime&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runs when&lt;/td>
&lt;td>I start a loop&lt;/td>
&lt;td>Every 12 minutes, whether I am there or not&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Audit trail&lt;/td>
&lt;td>Git history, which is the best audit trail there is&lt;/td>
&lt;td>Plane comments, which are receipts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fails at&lt;/td>
&lt;td>Bookkeeping overhead and file contention&lt;/td>
&lt;td>Bad work breakdown, silently&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Infrastructure to maintain&lt;/td>
&lt;td>Nothing beyond the repo&lt;/td>
&lt;td>Self-hosted Plane, 2 launchd runners, a watchdog, a reconciler&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The slice system is still the better fit inside a single deep codebase with a long dependency chain, because the queue and the code live in the same place and a slice can point at another slice&amp;rsquo;s merge commit. The parallel-batch pattern, three conflict-safe slices at once in three worktrees, is genuinely faster than one task per fire when the ready set is deep.&lt;/p>
&lt;p>Open Engine wins the moment work crosses a boundary. Deploy a container to Unraid. Fix a script on a different machine. File a follow-on issue against a different repo. None of that fits in a slice document and all of it fits in an issue. It also wins on the thing I actually cared about: it runs whether I am at the keyboard or not, and moving an item costs one API call rather than a pull request.&lt;/p>
&lt;p>Its cost is real and I should name it. I now maintain a self-hosted Plane instance, two launchd runners, a heartbeat watchdog, a PR-merge reconciler, and a PAT cached out of 1Password. When that breaks, no agent fixes it for me. It also took a couple of months of reading receipts before I trusted the runners enough to let them push, merge, and deploy on their own. Nothing about the architecture produced that trust. Watching it be right, over and over, is what produced it.&lt;/p>
&lt;h2 id="the-bottleneck-moved">The Bottleneck Moved
&lt;/h2>&lt;p>The loop is not the hard part anymore. Two runtimes polling a tracker every twelve minutes, each doing one task per fire in its own worktree, is a solved problem I have stopped thinking about. The heartbeat tells me if it stops.&lt;/p>
&lt;p>What I think about now is the cut. Whether a schema and its writer are in the same issue. Whether a dependency lives in the &lt;code>parent&lt;/code> field or only in a sentence. Whether an acceptance criterion checks the real interface or a mock. Those decide whether the work coming out the other side does anything, and none of them are automatable yet.&lt;/p>
&lt;p>I used to spend my time maintaining the queue. Now I spend it deciding what goes in it. That is a much better problem to have.&lt;/p></description></item></channel></rss>