<?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: Anton Shvein</title>
    <description>The latest articles on DEV Community by Anton Shvein (@t0ha).</description>
    <link>https://dev.to/t0ha</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%2F1416420%2F5e97108f-358a-4965-a68e-e6e00f3455f6.jpg</url>
      <title>DEV Community: Anton Shvein</title>
      <link>https://dev.to/t0ha</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/t0ha"/>
    <language>en</language>
    <item>
      <title>Supervising What You Don't Own: A Controller Loop in OTP</title>
      <dc:creator>Anton Shvein</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:52:15 +0000</pubDate>
      <link>https://dev.to/t0ha/supervising-what-you-dont-own-a-controller-loop-in-otp-cm</link>
      <guid>https://dev.to/t0ha/supervising-what-you-dont-own-a-controller-loop-in-otp-cm</guid>
      <description>&lt;p&gt;One morning I opened Camelot and saw a card stuck in the &lt;code&gt;executing&lt;/code&gt; column.&lt;br&gt;
The agent had been "working" on it since the previous day.&lt;/p&gt;

&lt;p&gt;The funny part is that the work was already done. The agent had written the&lt;br&gt;
code, pushed the branch, opened a pull request, and I had merged it myself the&lt;br&gt;
evening before. The board just didn't know about any of that.&lt;/p&gt;

&lt;p&gt;This post is about why that happened, and about the piece of code I had to&lt;br&gt;
write to make it stop. It seems to be a piece of code that many Elixir projects&lt;br&gt;
eventually need and few of us plan for: a controller loop that reconciles what&lt;br&gt;
the database believes with what the infrastructure is actually doing.&lt;/p&gt;
&lt;h2&gt;
  
  
  How Camelot runs agents
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://camelotai.tech?utm_source=dev.to&amp;amp;utm_medium=post&amp;amp;utm_campaign=2026-09-04-supervising-what-you-dont-own"&gt;Camelot AI&lt;/a&gt; runs coding agents on a kanban board. You&lt;br&gt;
create a task, an agent plans it, you approve the plan, the agent implements it&lt;br&gt;
and opens a PR.&lt;/p&gt;

&lt;p&gt;Each agent runs inside its own container. In production that means a &lt;a href="https://docs.docker.com/engine/swarm/" rel="noopener noreferrer"&gt;Docker&lt;br&gt;
Swarm&lt;/a&gt; service per task, and the BEAM talks to the Swarm manager over an HTTP&lt;br&gt;
socket proxy. A single run can take forty minutes.&lt;/p&gt;

&lt;p&gt;Inside the application, each task gets a &lt;code&gt;TaskRunner&lt;/code&gt; &lt;a href="https://hexdocs.pm/elixir/GenServer.html" rel="noopener noreferrer"&gt;GenServer&lt;/a&gt;. It builds the&lt;br&gt;
spec, starts the runner, streams output over PubSub to the LiveView, and&lt;br&gt;
finalises the session when the process exits.&lt;/p&gt;

&lt;p&gt;It looks like a normal OTP design. For a while it behaved like one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where OTP stops
&lt;/h2&gt;

&lt;p&gt;OTP supervises processes inside your VM. If a &lt;code&gt;TaskRunner&lt;/code&gt; crashes, its&lt;br&gt;
supervisor restarts it. If a process it monitors dies, it gets a &lt;code&gt;:DOWN&lt;/code&gt;&lt;br&gt;
message. This is the part everybody knows and loves.&lt;/p&gt;

&lt;p&gt;None of it applies to the agent.&lt;/p&gt;

&lt;p&gt;The agent is not a BEAM process. It is a CLI inside a container, possibly on&lt;br&gt;
another machine. My &lt;code&gt;TaskRunner&lt;/code&gt; does not supervise it directly. It &lt;em&gt;watches&lt;/em&gt; the container, over&lt;br&gt;
a network, through a proxy, with no delivery guarantees.&lt;/p&gt;

&lt;p&gt;And here is the thing that broke me: &lt;strong&gt;a deploy kills my processes but not my&lt;br&gt;
containers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I push to &lt;code&gt;develop&lt;/code&gt;, the app container is replaced. Every &lt;code&gt;TaskRunner&lt;/code&gt;&lt;br&gt;
dies. Meanwhile the agent containers are still out there, happily compiling&lt;br&gt;
somebody's Elixir project, with nobody left in the world listening to them.&lt;/p&gt;

&lt;p&gt;The database still says &lt;code&gt;status: :running&lt;/code&gt;. The world says something else.&lt;/p&gt;
&lt;h2&gt;
  
  
  The row outlives the process
&lt;/h2&gt;

&lt;p&gt;Once I accepted that, the design got a bit simpler to reason about.&lt;/p&gt;

&lt;p&gt;The durable thing is a &lt;code&gt;Session&lt;/code&gt; row in Postgres. The &lt;code&gt;TaskRunner&lt;/code&gt; GenServer is&lt;br&gt;
just the thing that happens to be attending to it right now. Processes are&lt;br&gt;
disposable. Rows are not.&lt;/p&gt;

&lt;p&gt;So after a restart, somebody has to walk the rows and ask, for each session that&lt;br&gt;
claims to be running: is this still true?&lt;/p&gt;

&lt;p&gt;That somebody is &lt;code&gt;Camelot.Runtime.Reconciler&lt;/code&gt;. A &lt;code&gt;GenServer&lt;/code&gt; that runs once at&lt;br&gt;
boot and then every sixty seconds. Probably the most valuable module in the&lt;br&gt;
whole runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;handle_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;do_reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;Process&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="ss"&gt;:tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;@tick_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:noreply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have written a Kubernetes controller, this looks familiar. Observe the&lt;br&gt;
desired state, observe the actual state, take the smallest action that moves&lt;br&gt;
one towards the other. Then do it again in a minute, because you were probably&lt;br&gt;
wrong about something.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adopting a run that lost its owner
&lt;/h2&gt;

&lt;p&gt;The first thing the reconciler does is find &lt;code&gt;:running&lt;/code&gt; sessions whose owning&lt;br&gt;
&lt;code&gt;TaskRunner&lt;/code&gt; no longer exists.&lt;/p&gt;

&lt;p&gt;The naive move is to mark them failed. Correct, and also terrible: you just&lt;br&gt;
threw away thirty minutes of an agent's work and real money because you&lt;br&gt;
deployed a CSS fix.&lt;/p&gt;

&lt;p&gt;So instead Camelot tries to &lt;strong&gt;adopt&lt;/strong&gt; them. If the container is still alive, we&lt;br&gt;
start a fresh &lt;code&gt;TaskRunner&lt;/code&gt; and re-attach it to the run already in progress.&lt;/p&gt;

&lt;p&gt;The problem is that after a restart we no longer have the original &lt;code&gt;docker exec&lt;/code&gt;&lt;br&gt;
id, so we cannot ask Docker for its exit status. The exec is gone as far as we&lt;br&gt;
are concerned, even though the process inside the container is fine.&lt;/p&gt;

&lt;p&gt;The solution lives in the container. Every agent invocation goes through a small&lt;br&gt;
wrapper script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/camelot-output-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CAMELOT_SESSION_ID&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;session&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.log"&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; +e
&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPESTATUS&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/camelot-exit-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CAMELOT_SESSION_ID&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;session&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two files, and the order matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The output is tee'd to a per-session log, so the complete result can be
fetched with a short &lt;code&gt;docker exec cat&lt;/code&gt; after the fact, instead of depending on
a long-lived, mostly idle exec stream.&lt;/li&gt;
&lt;li&gt;The exit code is written to a marker file &lt;strong&gt;last&lt;/strong&gt;, so its presence strictly
implies the output file is already complete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An adopting session polls for that marker. When it appears, we know the run&lt;br&gt;
finished and with what code, and we read the tee'd output next to it.&lt;/p&gt;

&lt;p&gt;I was quite pleased with this. It survived deploys. It recovered work. It felt&lt;br&gt;
robust.&lt;/p&gt;
&lt;h2&gt;
  
  
  The poll that never ends
&lt;/h2&gt;

&lt;p&gt;Here is the poll loop, roughly as I first wrote it: check for the marker, and if&lt;br&gt;
it is not there, sleep and check again.&lt;/p&gt;

&lt;p&gt;Read that sentence again and see if you can spot what is missing.&lt;/p&gt;

&lt;p&gt;There is no way out.&lt;/p&gt;

&lt;p&gt;If the marker never appears, the loop runs forever. And there is a very ordinary&lt;br&gt;
situation where the marker can never appear: &lt;strong&gt;the container was replaced.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Swarm reschedules a task. A node runs out of memory and kills the container.&lt;br&gt;
The boot sweep rolls the service onto a newer runner image. In all three cases a&lt;br&gt;
brand new container comes up with the same service name, perfectly healthy, and&lt;br&gt;
completely useless to us — the marker and the tee'd output lived in the previous&lt;br&gt;
container's &lt;code&gt;/tmp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My test cluster is two very small nodes. Reschedules are not a rare event there.&lt;br&gt;
They are Tuesday.&lt;/p&gt;

&lt;p&gt;So the sequence that produced my eighteen-hour card:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A session started executing.&lt;/li&gt;
&lt;li&gt;I deployed. The &lt;code&gt;TaskRunner&lt;/code&gt; died, the container kept running.&lt;/li&gt;
&lt;li&gt;The container got rescheduled onto another node. The marker died with it.&lt;/li&gt;
&lt;li&gt;The reconciler adopted the session and started polling for a file that no
longer existed anywhere in the universe.&lt;/li&gt;
&lt;li&gt;It polled. And polled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The session stayed &lt;code&gt;:running&lt;/code&gt;, so the task stayed in &lt;code&gt;executing&lt;/code&gt;. And every&lt;br&gt;
other recovery path politely stepped around it: the stale-session sweep saw a&lt;br&gt;
live owning process, so it skipped it. The stale-handle sweep saw a live&lt;br&gt;
service, so it skipped it. Everything was working exactly as designed.&lt;/p&gt;

&lt;p&gt;Meanwhile the agent had finished long ago and opened the PR. I merged it by&lt;br&gt;
hand. The board never noticed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: a decision with no I/O
&lt;/h2&gt;

&lt;p&gt;The fix itself is not complicated. What matters is &lt;em&gt;where&lt;/em&gt; I put it.&lt;/p&gt;

&lt;p&gt;I pulled the decision out of the polling loop entirely, into its own module with&lt;br&gt;
no I/O in it at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container_started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elapsed_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;cond&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;container_replaced?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container_started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_started_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:give_up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:container_replaced&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;elapsed_ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;budget_ms&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:give_up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:timeout&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="no"&gt;true&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
      &lt;span class="ss"&gt;:poll&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the check underneath it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;container_replaced?&lt;/span&gt;&lt;span class="p"&gt;(%&lt;/span&gt;&lt;span class="no"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;container_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="no"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session_started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;after?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;container_replaced?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_container_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_session_started&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the live container started &lt;em&gt;after&lt;/em&gt; the session's exec did, the marker is&lt;br&gt;
provably gone. Give up now, fail the session so it is user-recoverable, and&lt;br&gt;
re-queue the task.&lt;/p&gt;

&lt;p&gt;Two details I got wrong on the first attempt, and would like to save you:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fallback clause returns &lt;code&gt;false&lt;/code&gt;, not &lt;code&gt;true&lt;/code&gt;.&lt;/strong&gt; If either timestamp is&lt;br&gt;
unknown, we keep polling and let the wall-clock budget decide. A missing&lt;br&gt;
timestamp is not evidence of a dead container, and I would rather wait fifteen&lt;br&gt;
minutes than abandon a healthy run because Docker hiccuped on one request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The container start time is re-read on every iteration&lt;/strong&gt;, not sampled once at&lt;br&gt;
the beginning. A deploy can replace the container &lt;em&gt;while&lt;/em&gt; the adoption is&lt;br&gt;
already running. A sample taken a moment too early would keep the poll going for&lt;br&gt;
the whole budget.&lt;/p&gt;

&lt;p&gt;The wall-clock budget stays as a backstop. Two independent give-up conditions,&lt;br&gt;
because the precise one depends on timestamps I might not have.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the pure function matters
&lt;/h2&gt;

&lt;p&gt;The polling loop needs Docker, a node proxy, a running Swarm, and forty minutes&lt;br&gt;
of patience. The decision needs four values.&lt;/p&gt;

&lt;p&gt;Once they are separated, the test for the incident that cost me a day is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"gives up when the container was replaced after the session exec"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;container&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-14T12:47:00Z"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-14T10:44:00Z"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;assert&lt;/span&gt; &lt;span class="no"&gt;AdoptPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;900_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt;
           &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:give_up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:container_replaced&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real timestamps from the real incident. No mocks, no Docker, milliseconds to&lt;br&gt;
run. And because the module is pure, the moduledoc examples are doctests and the&lt;br&gt;
documentation cannot drift away from the behaviour.&lt;/p&gt;

&lt;p&gt;This is the part I would most like you to take from the article. A controller&lt;br&gt;
loop is mostly &lt;em&gt;policy&lt;/em&gt;, and policy has no business being tangled up with the&lt;br&gt;
I/O that feeds it. The reconciler does the same trick for recovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;recovery_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LocalPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_presence&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ss"&gt;:fail&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;recovery_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_backend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:bootstrap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_presence&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ss"&gt;:fail&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;recovery_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_backend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_presence&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ss"&gt;:fail&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;recovery_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_backend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:present&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ss"&gt;:adopt&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;recovery_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_backend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_presence&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ss"&gt;:fail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five clauses. No side effects. Exactly the kind of code Elixir is good at, and&lt;br&gt;
exactly the kind that ends up buried in the middle of a &lt;code&gt;handle_info&lt;/code&gt; if you are&lt;br&gt;
not deliberate about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of the steps matters
&lt;/h2&gt;

&lt;p&gt;One more trap, because it took me a second incident to see it.&lt;/p&gt;

&lt;p&gt;The boot sweep re-pins runner images to the newest digest. That &lt;em&gt;replaces&lt;br&gt;
containers&lt;/em&gt; — which means the boot sweep itself creates the very condition that&lt;br&gt;
dooms an adoption.&lt;/p&gt;

&lt;p&gt;So the recovery of rolled tasks has to run &lt;strong&gt;before&lt;/strong&gt; the stale-session sweep.&lt;br&gt;
Otherwise the sweep hands a session we already know is doomed to an adoption&lt;br&gt;
that will poll for fifteen minutes before reaching the same conclusion.&lt;/p&gt;

&lt;p&gt;In a controller loop, the order of your reconciliation steps is not an&lt;br&gt;
implementation detail. It is part of the contract, and it deserves a comment&lt;br&gt;
saying so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself six months ago
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every wait needs a deadline.&lt;/strong&gt; Not most waits. Every one. If you write a
poll loop with no exit condition, you have written a hang and just not met it
yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database row is the process.&lt;/strong&gt; Anything you want to survive a deploy has
to be a row, not GenServer state. The GenServer is a fast index over the rows,
rebuilt on boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polling is your &lt;code&gt;Process.monitor&lt;/code&gt;.&lt;/strong&gt; You do not get &lt;code&gt;:DOWN&lt;/code&gt; messages from
another machine. Adopt, fail, retry — you are writing a restart strategy by
hand, so write it like one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never assume you finish in the container you started in.&lt;/strong&gt; Sounds obvious.
Responsible for most of the bugs in this post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are new ideas. Kubernetes controllers have worked this way for&lt;br&gt;
years. What surprised me is how naturally the whole thing sits in OTP once you&lt;br&gt;
stop expecting supervision to reach across the network for you. A GenServer, a&lt;br&gt;
sixty-second tick, some pure decision functions and a couple of Ash queries make&lt;br&gt;
a genuinely small controller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The stuck card was not a Docker bug or a Swarm bug. It was me assuming that a&lt;br&gt;
file I wrote would still be there when I came back for it.&lt;/p&gt;

&lt;p&gt;OTP is very good at supervising things it owns. The moment your real workload&lt;br&gt;
lives somewhere else — a container, another node, somebody else's API — you have&lt;br&gt;
to rebuild the guarantees yourself, and it seems better to rebuild them&lt;br&gt;
explicitly: durable state, a bounded wait, a policy you can test, and a loop&lt;br&gt;
that runs again in a minute because you were probably wrong about something.&lt;/p&gt;

&lt;p&gt;Next in this series: &lt;a href="https://t0ha.dev/en/elixir/devops/2026/09/18/63-dollars-in-one-night.html?utm_source=dev.to&amp;amp;utm_medium=post&amp;amp;utm_campaign=2026-09-04-supervising-what-you-dont-own"&gt;what happens when a polling trigger sits in front of something that costs money on every tick&lt;/a&gt;.&lt;br&gt;
That one cost me $63 in a single night.&lt;/p&gt;

&lt;p&gt;The process is going on...&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>otp</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>OpenTelemetry Collector on Docker Swarm</title>
      <dc:creator>Anton Shvein</dc:creator>
      <pubDate>Thu, 22 Oct 2026 06:24:48 +0000</pubDate>
      <link>https://dev.to/t0ha/opentelemetry-collector-on-docker-swarm-1kk1</link>
      <guid>https://dev.to/t0ha/opentelemetry-collector-on-docker-swarm-1kk1</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%2Frig6h99t10nvxqucry12.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%2Frig6h99t10nvxqucry12.png" alt="Diagram of containers on two Swarm nodes sending logs and traces through a per-node OTel agent to a shared gateway, which exports to PostHog" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two clusters, running for months, and no logs anywhere except &lt;code&gt;docker service&lt;br&gt;
logs&lt;/code&gt; and my own patience. I decided to fix that.&lt;/p&gt;

&lt;p&gt;I thought it is a configuration task. Write some YAML, point it at a backend, go&lt;br&gt;
and do something else. It seems it was not.&lt;/p&gt;

&lt;p&gt;Every version of that YAML passed &lt;code&gt;otelcol validate&lt;/code&gt;. Every one. Including the&lt;br&gt;
version that lost data, the one that shipped every line twice, the one that&lt;br&gt;
filled a disk with its own output, and the one whose regex could never match&lt;br&gt;
anything at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Idea
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://camelotai.tech?utm_source=blog&amp;amp;utm_medium=article&amp;amp;utm_campaign=swarm-logs" rel="noopener noreferrer"&gt;Camelot AI&lt;/a&gt; runs on two small Docker Swarm clusters. Test&lt;br&gt;
and production, two nodes each: a 1 GB manager that is always a bit close to the&lt;br&gt;
edge, and a big ARM worker. The goal was ordinary — container logs, node and&lt;br&gt;
container metrics, and an OTLP endpoint for traces I haven't written yet.&lt;br&gt;
Everything to &lt;a href="https://posthog.com?utm_source=blog&amp;amp;utm_medium=article&amp;amp;utm_campaign=t0ha.dev" rel="noopener noreferrer"&gt;PostHog&lt;/a&gt;, which I already use.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;I ended with two components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;strong&gt;agent&lt;/strong&gt; on every node, as a Swarm global service, reading container logs
off that node's own disk;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;gateway&lt;/strong&gt;, one replica, holding the credentials and talking out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent-per-node is not a design preference. Container logs live in&lt;br&gt;
&lt;code&gt;/var/lib/docker/containers&lt;/code&gt; on the machine that ran the container. One central&lt;br&gt;
collector can't see other nodes' logs, no matter how you configure it.&lt;/p&gt;
&lt;h2&gt;
  
  
  It ingested its own logs
&lt;/h2&gt;

&lt;p&gt;The first run on a real node produced 1.8 million lines of debug output in about&lt;br&gt;
seventy seconds. On a node with 31 GB free it is not a comfortable rate.&lt;/p&gt;

&lt;p&gt;Almost all of it came from one container — the collector itself. It discovers&lt;br&gt;
containers over the Docker socket and tails each one's log file. It discovered&lt;br&gt;
itself. So it read its own output, and printing anything about a log record&lt;br&gt;
produced another record, which it then read. A small loop, running at whatever&lt;br&gt;
speed the disk allowed.&lt;/p&gt;

&lt;p&gt;The fix is two lines of &lt;code&gt;excluded_images&lt;/code&gt;. I would never have written them from&lt;br&gt;
the documentation — you need to see the loop first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;docker_observer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unix:///var/run/docker.sock&lt;/span&gt;
    &lt;span class="c1"&gt;# most containers here publish no ports, and without this they are&lt;/span&gt;
    &lt;span class="c1"&gt;# never discovered at all&lt;/span&gt;
    &lt;span class="na"&gt;include_all_containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="c1"&gt;# globs, because the deployed tag is a commit sha&lt;/span&gt;
    &lt;span class="na"&gt;excluded_images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*otel-agent*"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*otel-gateway*"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*opentelemetry-collector-contrib*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later I excluded the gateway too, for the mirror reason: a failing export makes&lt;br&gt;
the gateway log, and shipping that log back through the gateway amplifies exactly&lt;br&gt;
the failure it reports.&lt;/p&gt;
&lt;h2&gt;
  
  
  The duplicate
&lt;/h2&gt;

&lt;p&gt;The numbers still looked high. Every line from one container appeared exactly&lt;br&gt;
twice.&lt;/p&gt;

&lt;p&gt;The observer has an option &lt;code&gt;include_all_containers&lt;/code&gt;. I read it as "emit an&lt;br&gt;
endpoint for every container", which is true, and missed the point: it emits a&lt;br&gt;
port-less endpoint &lt;strong&gt;in addition to&lt;/strong&gt; one per exposed port. A container&lt;br&gt;
publishing two ports is matched three times. Three filelog receivers on one&lt;br&gt;
file. Every line shipped three times, to a backend that bills by volume.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;port == 0&lt;/code&gt; in the discovery rule. Obvious once you see the&lt;br&gt;
duplicates, invisible before.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver_creator/containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;watch_observers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;docker_observer&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;filelog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# port == 0 is the single port-less endpoint - matches once,&lt;/span&gt;
        &lt;span class="c1"&gt;# not once per exposed port&lt;/span&gt;
        &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type == "container" &amp;amp;&amp;amp; port == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/containers/`container_id`/`container_id`-json.log&lt;/span&gt;
          &lt;span class="na"&gt;start_at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;beginning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Making it safer lost data
&lt;/h2&gt;

&lt;p&gt;This one I like the most, because I did it to myself on purpose.&lt;/p&gt;

&lt;p&gt;A review comment suggested lowering the memory limiter from 160 MiB to 128, to&lt;br&gt;
leave more headroom on the 1 GB manager. It sounds sensible — the node really is&lt;br&gt;
OOM-prone. I agreed and changed it.&lt;/p&gt;

&lt;p&gt;Then I ran it, and the log said &lt;code&gt;data refused due to high memory usage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With the tighter limit, the limiter refused batches while the agent worked&lt;br&gt;
through the backlog of existing logs — and the filelog receivers, when refused,&lt;br&gt;
dropped those records instead of waiting. I made the collector safer against OOM&lt;br&gt;
by making it lose logs. The whole point of the collector is to not lose logs.&lt;/p&gt;

&lt;p&gt;The real fix was not a number. It was &lt;code&gt;retry_on_failure&lt;/code&gt; on the receivers, so&lt;br&gt;
back-pressure replaces dropping: when the limiter bites, the receiver stops and&lt;br&gt;
waits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;memory_limiter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;check_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;limit_mib&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;160&lt;/span&gt;
    &lt;span class="na"&gt;spike_limit_mib&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40&lt;/span&gt;

&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;receiver_creator/containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;filelog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# without this the receiver drops records outright whenever&lt;/span&gt;
          &lt;span class="c1"&gt;# the limiter refuses a batch, instead of waiting for it&lt;/span&gt;
          &lt;span class="na"&gt;retry_on_failure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;initial_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
            &lt;span class="na"&gt;max_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then 160 MiB was fine. A real node settles around 137 MiB.&lt;/p&gt;

&lt;p&gt;I would have merged the 128 and felt responsible about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fix that was not a fix
&lt;/h2&gt;

&lt;p&gt;In PostHog every metric arrived with &lt;code&gt;service&lt;/code&gt; set to &lt;code&gt;unknown&lt;/code&gt;. That part was&lt;br&gt;
easy — metrics carried no &lt;code&gt;service.name&lt;/code&gt; at all, and &lt;code&gt;unknown&lt;/code&gt; is what a backend&lt;br&gt;
shows when it's missing. Logs had one, metrics never did.&lt;/p&gt;

&lt;p&gt;So I wrote a transform to fill it from the Swarm service label, ran it, and&lt;br&gt;
&lt;code&gt;unknown&lt;/code&gt; was gone. Fixed.&lt;/p&gt;

&lt;p&gt;Except the names read &lt;code&gt;srv-captain--db.1.mms89736vzjrjpsfn6yagtb0w&lt;/code&gt;. The Swarm&lt;br&gt;
label branch never matched. &lt;code&gt;docker_stats&lt;/code&gt; promotes those labels to &lt;strong&gt;resource&lt;/strong&gt;&lt;br&gt;
attributes, not data point attributes, despite the option being called&lt;br&gt;
&lt;code&gt;container_labels_to_metric_labels&lt;/code&gt;, and I read them in the wrong context, where&lt;br&gt;
they are always nil. Everything fell through to the fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;docker_stats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# this is where docker.service.name comes from&lt;/span&gt;
    &lt;span class="na"&gt;container_labels_to_metric_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;com.docker.swarm.service.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.service.name&lt;/span&gt;

&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# wrong - datapoint context, where the label is always nil&lt;/span&gt;
  &lt;span class="na"&gt;transform/service_name_wrong&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metric_statements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;datapoint&lt;/span&gt;
        &lt;span class="na"&gt;statements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(resource.attributes["service.name"], attributes["docker.service.name"])&lt;/span&gt;

  &lt;span class="c1"&gt;# right - despite the option's name, docker_stats puts it on the resource&lt;/span&gt;
  &lt;span class="na"&gt;transform/service_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metric_statements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;resource&lt;/span&gt;
        &lt;span class="na"&gt;statements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(resource.attributes["service.name"], resource.attributes["docker.service.name"])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I only checked that &lt;code&gt;unknown&lt;/code&gt; disappeared, I would have shipped that.&lt;/p&gt;

&lt;p&gt;The obvious alternative would have failed too, for another reason. Trimming the&lt;br&gt;
task suffix off the container name works for replicated services, named&lt;br&gt;
&lt;code&gt;service.slot.task&lt;/code&gt;, and quietly breaks for global ones, named&lt;br&gt;
&lt;code&gt;service.node-id.task&lt;/code&gt; — which is exactly what my own agent is.&lt;/p&gt;
&lt;h2&gt;
  
  
  The escape hatch
&lt;/h2&gt;

&lt;p&gt;The agent has to run on every node, which in Swarm means a global service.&lt;br&gt;
CapRover, which manages my clusters, has a Service Update Override field for&lt;br&gt;
exactly this. So I put the mode in it, and it refused to save: &lt;code&gt;must specify&lt;br&gt;
only one service mode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its merge is additive. It merges &lt;code&gt;Global&lt;/code&gt; next to the generated &lt;code&gt;Replicated&lt;/code&gt;&lt;br&gt;
instead of replacing it, and Docker rejects a spec carrying both. There is an&lt;br&gt;
&lt;a href="https://github.com/caprover/caprover/issues/2198" rel="noopener noreferrer"&gt;open issue from 2024&lt;/a&gt; with a&lt;br&gt;
suggested workaround using a pre-deploy script.&lt;/p&gt;

&lt;p&gt;The workaround can't work, and it took reading CapRover's source on the manager&lt;br&gt;
to see why. It creates every app's service from a placeholder image first, so&lt;br&gt;
the service always exists as replicated before any deploy, and the daemon&lt;br&gt;
refuses mode changes on an existing service: &lt;code&gt;service mode change is not&lt;br&gt;
allowed&lt;/code&gt;. No update-time manipulation converts it.&lt;/p&gt;

&lt;p&gt;A service has to be &lt;em&gt;created&lt;/em&gt; global. So: let CapRover deploy normally, then&lt;br&gt;
recreate the service from the spec CapRover itself generated, with only the mode&lt;br&gt;
swapped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;otel&lt;/span&gt;&lt;span class="p"&gt;-agent&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# idempotent: a global service is already what we want&lt;/span&gt;
docker service inspect &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{json .Spec.Mode}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; Global &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="c"&gt;# reuse the spec CapRover generated, and change only the mode. rebuilding&lt;/span&gt;
&lt;span class="c"&gt;# it by hand would drop the labels, env, limits and placement it set.&lt;/span&gt;
docker service inspect &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{json .Spec}}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/spec.json
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
import json
spec = json.load(open("/tmp/spec.json"))
spec["Mode"] = {"Global": {}}
json.dump(spec, open("/tmp/global.json", "w"))
'&lt;/span&gt;

docker service &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--unix-socket&lt;/span&gt; /var/run/docker.sock &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; @/tmp/global.json &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost/v1.44/services/create
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that CapRover manages it happily, because merging &lt;code&gt;Global&lt;/code&gt; into&lt;br&gt;
an already-global spec is a no-op.&lt;/p&gt;

&lt;p&gt;A short bash script, and both clusters survived several redeploys since. I put&lt;br&gt;
the findings on the upstream issue — it felt honest after taking the&lt;br&gt;
maintainer's time indirectly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Everything was info
&lt;/h2&gt;

&lt;p&gt;By this point the pipeline was healthy by every measure I had. Zero export&lt;br&gt;
errors, memory where I expected it, both clusters green. Then I looked at the&lt;br&gt;
data, and every record said &lt;code&gt;info&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not most of them. Every one. A Postgrex disconnect and a failed schema migration&lt;br&gt;
sat in PostHog at the same level as a 200 response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;body: "07:11:40.138 [error] Postgrex.Protocol ... disconnected: ... timed out"
severity_text: info    severity_number: 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;container&lt;/code&gt; operator unwraps Docker's JSON envelope — &lt;code&gt;log&lt;/code&gt;, &lt;code&gt;stream&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;time&lt;/code&gt; — and stops there. The application's own line stays raw text, so nothing&lt;br&gt;
sets severity, and PostHog shows unset severity as &lt;code&gt;info&lt;/code&gt;. The collector was&lt;br&gt;
working perfectly and the result was useless.&lt;/p&gt;

&lt;p&gt;So I wrote a regex to pull the level out of the Elixir format. It matched&lt;br&gt;
nothing. I had written it as a folded YAML scalar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# matches nothing, and validates fine&lt;/span&gt;
&lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
  &lt;span class="s"&gt;^\d{2}:\d{2}:\d{2}\.\d{3}&lt;/span&gt;
  &lt;span class="s"&gt;(?:request_id=(?P&amp;lt;request_id&amp;gt;\S+) )?&lt;/span&gt;
  &lt;span class="s"&gt;\[(?P&amp;lt;level&amp;gt;[a-z]+)\]&lt;/span&gt;

&lt;span class="c1"&gt;# works - one line, single-quoted so the backslashes survive&lt;/span&gt;
&lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^\d{2}:\d{2}:\d{2}\.\d{3}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;(?:request_id=(?P&amp;lt;request_id&amp;gt;\S+)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;)?\[(?P&amp;lt;level&amp;gt;[a-z]+)\]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;gt;-&lt;/code&gt; joins its lines with spaces. That puts an extra space before the bracket,&lt;br&gt;
so the pattern can't match a real line. &lt;code&gt;otelcol validate&lt;/code&gt; accepted it, because&lt;br&gt;
it is a perfectly valid regex. It just fires never.&lt;/p&gt;

&lt;p&gt;Then the regex worked, and it still wasn't right. Docker gives the collector one&lt;br&gt;
record per physical line, so a text-formatted stack trace arrives as several&lt;br&gt;
records and only the first carries &lt;code&gt;[error]&lt;/code&gt;. The rest strand at the default&lt;br&gt;
level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"09:58:10.874 [error] Could not create schema migrations table..."  -&amp;gt; error
"  * The database does not exist"                                   -&amp;gt; info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No pattern fixes that — the lines are separate records before the collector ever&lt;br&gt;
sees them. So released builds log JSON now. JSON escapes the newlines, so one&lt;br&gt;
event stays one line and one record, and every metadata key arrives as a field&lt;br&gt;
instead of being recovered by guesswork.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/runtime.exs, :prod only - mix phx.server stays readable&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="ss"&gt;:logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:default_handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;formatter:&lt;/span&gt; &lt;span class="no"&gt;LoggerJSON&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Formatters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="no"&gt;Basic&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;metadata:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:mfa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:crash_reason&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One last trap on the way out. Severity parsers keep the word the application&lt;br&gt;
used, and Elixir says &lt;code&gt;warning&lt;/code&gt;. PostHog filters severity by exact match against&lt;br&gt;
six lowercase buckets, so &lt;code&gt;warning&lt;/code&gt; never matches &lt;code&gt;warn&lt;/code&gt;. The level looked&lt;br&gt;
parsed and stayed unfilterable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;otelcol validate&lt;/code&gt; accepted every broken version of this config. It was right&lt;br&gt;
to — the schema was valid every time.&lt;/p&gt;

&lt;p&gt;Validation proves shape. It doesn't know that a discovery rule matches three&lt;br&gt;
times, that a receiver can see its own output, that a memory limit interacts&lt;br&gt;
with a backlog, or that a label lives in a different context than you assumed.&lt;br&gt;
Those are behaviours, and behaviour only exists on a real node with real&lt;br&gt;
containers. Most of the problems here would have passed any review I could&lt;br&gt;
write, because the YAML looks correct. It &lt;em&gt;is&lt;/em&gt; correct, in the sense a linter&lt;br&gt;
means. The folded scalar is the purest case: a valid regex, a valid config, a&lt;br&gt;
green check, and a pattern that can never match a single line.&lt;/p&gt;

&lt;p&gt;There is one nice detail in all this. Containers here arise and cease all the&lt;br&gt;
time — a task container lives forty minutes, a probe container lives one second&lt;br&gt;
— and the only thing that persists across them is a small file of read offsets&lt;br&gt;
on the host. Impermanence (མི་རྟག་པ།) is not a metaphor in a Swarm cluster. It&lt;br&gt;
is the scheduling model, and if you want to remember anything you have to write&lt;br&gt;
it down somewhere the scheduler can't reach.&lt;/p&gt;

&lt;p&gt;Both clusters now ship logs and metrics, with zero export errors and about 137&lt;br&gt;
MiB on the node I was worried about. Traces are ingesting and empty, because I&lt;br&gt;
haven't instrumented the app yet. That's the next piece.&lt;/p&gt;

&lt;p&gt;Neither cluster has Docker log rotation configured, which I found when the&lt;br&gt;
parser choked on six truncated records left by a disk-full event two weeks&lt;br&gt;
earlier. It is still not configured. It needs a &lt;code&gt;dockerd&lt;/code&gt; restart per node, and&lt;br&gt;
I haven't decided when.&lt;/p&gt;

&lt;p&gt;If there is one thing worth taking from this, it is the memory limiter. A&lt;br&gt;
sensible suggestion, applied carefully, made the system lose the exact data it&lt;br&gt;
exists to collect — and the only reason I know is that I ran it and read the&lt;br&gt;
output, instead of trusting that a smaller number is a safer number.&lt;/p&gt;

&lt;p&gt;The process is going on...&lt;/p&gt;

</description>
      <category>observability</category>
      <category>devops</category>
      <category>docker</category>
      <category>opentelemetry</category>
    </item>
    <item>
      <title>$63 in One Night: When Your Elixir Polling Loop Costs Money</title>
      <dc:creator>Anton Shvein</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:06:53 +0000</pubDate>
      <link>https://dev.to/t0ha/63-in-one-night-when-your-elixir-polling-loop-costs-money-1c26</link>
      <guid>https://dev.to/t0ha/63-in-one-night-when-your-elixir-polling-loop-costs-money-1c26</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%2F4aye8007c40vql2qfnse.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%2F4aye8007c40vql2qfnse.png" alt="Camelot task page with a Sessions panel listing one agent run after another, each marked failed with exit 255 and no JSON object found in output" width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I opened the board one morning and one card looked wrong. Not broken — busy.&lt;br&gt;
It had a wall of activity behind it from the previous evening, and I had not&lt;br&gt;
touched it since lunch.&lt;/p&gt;

&lt;p&gt;My stomach did the thing it does before I even know why. I went digging to find&lt;br&gt;
out how much that evening had cost me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;243 agent runs in eight hours. About 31 million input tokens. $64.25 spent, of&lt;br&gt;
which $63.10 was pure waste. Zero commits produced.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I read that number a few times. Sixty-three dollars, for nothing, while I slept.&lt;/p&gt;

&lt;p&gt;Then the second part landed, and that one was worse. I did not write the code&lt;br&gt;
that did this. Claude Code wrote it, inside Camelot, on a task I created myself.&lt;br&gt;
I read the diff. I approved it. I merged it. It looked fine to me.&lt;/p&gt;

&lt;p&gt;The loop did not stop because I noticed it, or because a guard caught it. It&lt;br&gt;
stopped because the runner eventually crashed on malformed output and pushed the&lt;br&gt;
task into an error state. Even the crash felt, in hindsight, like the most&lt;br&gt;
responsible member of the team.&lt;/p&gt;

&lt;p&gt;So this is the story of a three-line cron trigger, of what happens when you put&lt;br&gt;
something expensive behind it, and of a code review where I was looking at all&lt;br&gt;
the wrong things.&lt;/p&gt;
&lt;h2&gt;
  
  
  A two-minute GitHub poller in Ash and Oban
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://camelotai.tech?utm_source=dev.to&amp;amp;utm_medium=blog&amp;amp;utm_campaign=public-beta"&gt;Camelot AI&lt;/a&gt;&lt;br&gt;
a task moves through stages on a kanban&lt;br&gt;
board. When the agent opens a pull request, the task lands in the &lt;code&gt;pr&lt;/code&gt; stage and&lt;br&gt;
a poller takes over: every two minutes it checks GitHub and reacts. Merged →&lt;br&gt;
done. CI failing → send the agent back in. Reviewer requested changes → send the&lt;br&gt;
agent back in.&lt;/p&gt;

&lt;p&gt;That poller was itself a Camelot task. I described what I wanted, the agent went&lt;br&gt;
away, opened a PR, and I read it on a Tuesday evening. With Ash and Oban the&lt;br&gt;
trigger is almost nothing to write, and what came back was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;triggers&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;trigger&lt;/span&gt; &lt;span class="ss"&gt;:check_pr_status&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:check_pr_status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;scheduler_cron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"*/2 * * * *"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:github&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_nil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pr_number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt;
          &lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:pr&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt;
          &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:active&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole thing. &lt;a href="https://hexdocs.pm/ash_oban" rel="noopener noreferrer"&gt;AshOban&lt;/a&gt; takes the &lt;code&gt;where&lt;/code&gt;&lt;br&gt;
expression, finds matching&lt;br&gt;
rows every two minutes, and runs the action on each of them. I have written&lt;br&gt;
dozens of these by hand. They are boring and reliable and I like them very much.&lt;/p&gt;

&lt;p&gt;Which is exactly why I read this one so fast. I recognised the shape, and the&lt;br&gt;
moment I recognised it I stopped reading and started scrolling.&lt;/p&gt;

&lt;p&gt;The problem is that this one is boring and reliable in front of something that&lt;br&gt;
starts a container and buys LLM tokens.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually happened?
&lt;/h2&gt;

&lt;p&gt;Further down the same diff — well past the point where I was still reading&lt;br&gt;
carefully — there was a branch that read, in spirit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;cond&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="n"&gt;has_review_state?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"CHANGES_REQUESTED"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;request_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reviews&lt;/code&gt; is what &lt;a href="https://docs.github.com/en/rest/pulls/reviews" rel="noopener noreferrer"&gt;GitHub's reviews endpoint&lt;/a&gt;&lt;br&gt;
returns. And here is the thing&lt;br&gt;
neither of us thought about carefully enough — though only one of us was supposed&lt;br&gt;
to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub keeps every review ever submitted, forever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A review is not a flag you clear. It is an immutable historical record. If a&lt;br&gt;
reviewer requested changes once, that entry is in the list on every subsequent&lt;br&gt;
call, for the rest of the pull request's life. Dismissing it adds a &lt;em&gt;new&lt;/em&gt; review&lt;br&gt;
with state &lt;code&gt;DISMISSED&lt;/code&gt; — the old one stays.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;has_review_state?(reviews, "CHANGES_REQUESTED")&lt;/code&gt; was not asking "does this PR&lt;br&gt;
need work?". It was asking "has anyone ever asked for changes on this PR?". Once&lt;br&gt;
true, it was true forever.&lt;/p&gt;

&lt;p&gt;I read that sentence three times before it landed. The function name is honest,&lt;br&gt;
by the way. &lt;code&gt;has_review_state?&lt;/code&gt; says precisely what it does. I read it as "does&lt;br&gt;
this PR need work?" because that is what I wanted it to mean, and because the&lt;br&gt;
line under it did the thing I expected the line to do.&lt;/p&gt;

&lt;p&gt;Every two minutes, the trigger matched the row. Every two minutes, the condition&lt;br&gt;
was true. Every two minutes, Camelot dispatched an agent to go fix feedback that&lt;br&gt;
had been addressed hours earlier.&lt;/p&gt;

&lt;p&gt;And the agent, to its credit, kept looking, finding nothing to do, and exiting&lt;br&gt;
cleanly. 243 times. More discipline than I showed in that review, honestly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Level-triggered, with a price tag
&lt;/h2&gt;

&lt;p&gt;The name for this is a level-triggered check. The code was testing a &lt;em&gt;state&lt;/em&gt;&lt;br&gt;
("changes are requested") instead of an &lt;em&gt;event&lt;/em&gt; ("changes were requested, and I&lt;br&gt;
have not acted on it yet").&lt;/p&gt;

&lt;p&gt;Level-triggered is usually the right default in a reconciliation loop. It is what&lt;br&gt;
makes the loop self-healing: if you miss an event, the next pass still sees the&lt;br&gt;
state and fixes it. &lt;a href="https://t0ha.dev/en/elixir/devops/2026/09/04/supervising-what-you-dont-own.html" rel="noopener noreferrer"&gt;The first article in this series&lt;/a&gt;&lt;br&gt;
is an argument in favour of exactly that style.&lt;/p&gt;

&lt;p&gt;The difference is the actuator on the other end.&lt;/p&gt;

&lt;p&gt;In an ordinary Ash application, a level-triggered check that fires redundantly&lt;br&gt;
costs you a database query every two minutes, and you will never notice. Here the&lt;br&gt;
actuator provisions a container, clones a repository, and hands a language model&lt;br&gt;
about 130 thousand tokens of context.&lt;/p&gt;

&lt;p&gt;A redundant poll in an ordinary app is a rounding error. A redundant poll here is&lt;br&gt;
an invoice. That sentence is still a bit hard for me to write.&lt;/p&gt;

&lt;p&gt;There is a second-order effect that makes it worse. The prompt includes the PR&lt;br&gt;
conversation. Every useless run added its own noise to that conversation. So the&lt;br&gt;
cost per run climbed steadily through the night — from about $0.12 early on to&lt;br&gt;
$0.45 by the end. The loop was not just wasteful, it was &lt;em&gt;accelerating&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And I did not know any of this while it was happening. Nobody was watching. That&lt;br&gt;
is probably the loneliest part of this kind of failure: it happens at 3am, at its&lt;br&gt;
own quiet pace, and it sends you the bill at breakfast.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why none of the guards helped
&lt;/h2&gt;

&lt;p&gt;This thing was not built without limits. There were three mechanisms that should&lt;br&gt;
have stopped it, and the loop walked straight past all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The freshness check was dead code on that path.&lt;/strong&gt; There is a&lt;br&gt;
&lt;code&gt;pr_comments_seen_at&lt;/code&gt; timestamp precisely so feedback is not acted on twice. But&lt;br&gt;
it was only consulted by the &lt;em&gt;comments&lt;/em&gt; branch, which sits below the review&lt;br&gt;
branch in the same &lt;code&gt;cond&lt;/code&gt;. The review branch never reached it. The guard existed,&lt;br&gt;
was correct, and was unreachable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The branch reset the counter that was supposed to bound it.&lt;/strong&gt; Look again at the&lt;br&gt;
call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;request_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third argument is the auto-fix attempt counter. There is a cap on&lt;br&gt;
consecutive automatic re-dispatches — default 2 — so a PR the agent cannot fix&lt;br&gt;
gets left for a human. But the cap only counts merge-conflict and CI-failure&lt;br&gt;
fixes, and this branch passed &lt;code&gt;0&lt;/code&gt;, resetting the counter on every single pass.&lt;/p&gt;

&lt;p&gt;The guard was not bypassed by accident. The code actively disarmed it, twice an&lt;br&gt;
hour, all night. And that &lt;code&gt;0&lt;/code&gt; sat in the diff, in plain sight, three characters&lt;br&gt;
wide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The order of the branches made recovery impossible.&lt;/strong&gt; &lt;code&gt;CHANGES_REQUESTED&lt;/code&gt; was&lt;br&gt;
checked before &lt;code&gt;APPROVED&lt;/code&gt;. So even if the reviewer had come back and approved the&lt;br&gt;
PR, the loop would have kept firing — the earlier branch matched first and the&lt;br&gt;
approval was never reached.&lt;/p&gt;

&lt;p&gt;I want to be clear that none of these are exotic mistakes. Each one is the kind&lt;br&gt;
of thing that reads fine in review — I know that for a fact, because each one was&lt;br&gt;
on my screen and I read it fine. For about half an hour that morning I tried to&lt;br&gt;
make this the agent's failure. It is not. The agent wrote plausible code, which&lt;br&gt;
is what it does, and it wrote the pattern I have written myself dozens of times.&lt;br&gt;
That is exactly why it got through: it looked like my code. I am the approval&lt;br&gt;
gate, and the gate opened.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: compare the review against the last commit
&lt;/h2&gt;

&lt;p&gt;Two changes, and the important one is not the obvious one. This time I wrote them&lt;br&gt;
by hand — not because that is the lesson, but because that is where my confidence&lt;br&gt;
was that morning.&lt;/p&gt;

&lt;p&gt;First, reduce the review list to each reviewer's &lt;em&gt;current&lt;/em&gt; verdict before asking&lt;br&gt;
anything about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="nv"&gt;@review_states&lt;/span&gt; &lt;span class="sx"&gt;~w(APPROVED CHANGES_REQUESTED DISMISSED)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;latest_reviews&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;reviews&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;&amp;amp;1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;@review_states&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;reviewer_login&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;submitted&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;submitted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;review_date&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group by reviewer, take the latest state-bearing review from each. &lt;code&gt;COMMENTED&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;PENDING&lt;/code&gt; carry no verdict and never supersede one. Now a reviewer who&lt;br&gt;
requested changes and later approved reads as approved, which is what a human&lt;br&gt;
would say if you asked them.&lt;/p&gt;

&lt;p&gt;Second — and this is the part that actually stops the loop — apply the same&lt;br&gt;
staleness guards to reviews that the comment path already used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;changes_requested?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_commit_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;reviews&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;latest_reviews&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Enum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;any?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"CHANGES_REQUESTED"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt;
      &lt;span class="n"&gt;unaddressed?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;last_commit_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A verdict has to be &lt;strong&gt;newer than the last commit&lt;/strong&gt; — pushing a fix addresses it —&lt;br&gt;
&lt;strong&gt;and unseen&lt;/strong&gt; since the last dispatch. Two independent reasons to consider a&lt;br&gt;
review handled.&lt;/p&gt;

&lt;p&gt;On the actual incident this alone would have ended it in one pass. The review was&lt;br&gt;
submitted at 11:46:46Z. The head commit was 11:52:34Z. The review was already&lt;br&gt;
stale before the loop even started. One timestamp comparison. That is the whole&lt;br&gt;
tragedy.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other bug: an optional call inside a &lt;code&gt;with&lt;/code&gt; chain
&lt;/h2&gt;

&lt;p&gt;While I was in there I found a second failure with the same flavour — same PR,&lt;br&gt;
same review, also mine to catch and also missed. It is worth its own paragraph&lt;br&gt;
because it is a pure Elixir idiom mistake, and the kind that is very easy to&lt;br&gt;
approve.&lt;/p&gt;

&lt;p&gt;The poller makes five GitHub calls, chained in one &lt;code&gt;with&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pr_data&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_pull_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list_pull_request_reviews&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list_pull_request_comments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;commits&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;list_pull_request_commits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check_runs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;fetch_check_runs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="no"&gt;Logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Failed to check PR for task &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks like good Elixir. It mostly is. That is the trap — it is idiomatic&lt;br&gt;
enough that your eye slides over it. But the last call needs a &lt;strong&gt;Checks: Read&lt;/strong&gt;&lt;br&gt;
permission on the GitHub App installation, and without it GitHub returns 403.&lt;/p&gt;

&lt;p&gt;One 403, and the whole &lt;code&gt;with&lt;/code&gt; collapses into a log line. Merge-conflict handling,&lt;br&gt;
review handling, comment handling, merged detection, closed detection — all of it&lt;br&gt;
stopped, for every task in the &lt;code&gt;pr&lt;/code&gt; stage, silently, because of an optional&lt;br&gt;
enrichment call.&lt;/p&gt;

&lt;p&gt;The fix is to be explicit that this one call is allowed to fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="nv"&gt;@spec&lt;/span&gt; &lt;span class="n"&gt;best_effort_check_runs&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;()]}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt; &lt;span class="p"&gt;::&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;()]}&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;best_effort_check_runs&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="n"&gt;best_effort_check_runs&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="ss"&gt;:error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;Logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"check-runs unavailable (&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;); treating as no checks"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="ss"&gt;:ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Degrade to "no checks" and keep going. CI failures go undetected until the&lt;br&gt;
permission is granted, which is annoying but survivable. Everything else keeps&lt;br&gt;
working, which is the point.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;with&lt;/code&gt; gives you one error channel for a whole chain. That is the feature. It&lt;br&gt;
becomes a bug the moment one link in the chain is not actually essential, and it&lt;br&gt;
fails quietly because the &lt;code&gt;else&lt;/code&gt; clause is usually where you put the logging and&lt;br&gt;
stop thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in how I work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I was reviewing agent diffs for the wrong things.&lt;/strong&gt; I read for naming, for&lt;br&gt;
structure, for whether it fits the codebase, for whether I would have written it&lt;br&gt;
that way. All of that was fine here, which is precisely why I merged it. Now I&lt;br&gt;
try to ask three questions of any diff that touches a loop: what makes it fire,&lt;br&gt;
what makes it stop, and what does one pass cost. If the diff does not answer the&lt;br&gt;
second and the third, it is not ready, however good it looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A level-triggered check needs a termination condition, not just a trigger&lt;br&gt;
condition.&lt;/strong&gt; "Is this state true?" is not enough. "Is this state true &lt;em&gt;and have I&lt;br&gt;
not already handled it&lt;/em&gt;?" is the real question, and the second half has to be&lt;br&gt;
durable — a timestamp or a counter in the database, not something in memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never reset a guard inside the branch the guard protects.&lt;/strong&gt; If I had to name the&lt;br&gt;
single line that cost me $63, it is the &lt;code&gt;0&lt;/code&gt; in that function call. One character,&lt;br&gt;
which I did not write and did not question. I keep coming back to that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best-effort calls do not belong in a &lt;code&gt;with&lt;/code&gt; chain.&lt;/strong&gt; If a step is optional,&lt;br&gt;
write a function that makes it always succeed and say so in the docs. The type&lt;br&gt;
signature becomes the documentation: &lt;code&gt;{:ok, term} | {:error, term}&lt;/code&gt; going in,&lt;br&gt;
&lt;code&gt;{:ok, term}&lt;/code&gt; coming out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost is a first-class signal.&lt;/strong&gt; I had logs. I had session records. What I did&lt;br&gt;
not have was anything that would say "this task has run 243 times today" or "this&lt;br&gt;
task has spent $60". A counter and a threshold would have caught this in twenty&lt;br&gt;
minutes instead of eight hours. The&lt;br&gt;
&lt;a href="https://t0ha.dev/en/devops/2026/09/17/opentelemetry-collector-on-docker-swarm.html" rel="noopener noreferrer"&gt;OpenTelemetry collector I put on those Swarm nodes&lt;/a&gt;&lt;br&gt;
ships logs and traces already — it just does not know anything about dollars yet.&lt;br&gt;
That is now on the list, near the top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The trigger was three lines. The branch that looped was four. Nothing in this&lt;br&gt;
incident was complicated, and nothing in it would have mattered at all in a&lt;br&gt;
normal CRUD application — a redundant poll would have cost me a few hundred&lt;br&gt;
cheap queries and I would never have known.&lt;/p&gt;

&lt;p&gt;What changed is what sits on the other end of the loop. When the actuator costs&lt;br&gt;
money, "harmless redundant work" stops being a category. Every pass has to earn&lt;br&gt;
itself.&lt;/p&gt;

&lt;p&gt;And there is the part I find harder to write. Camelot exists because I think an&lt;br&gt;
agent's work should pass a human gate before it becomes real. The gate was there.&lt;br&gt;
It worked exactly as designed. The gate is me, on a Tuesday evening, looking at a&lt;br&gt;
diff that resembled something I would have written myself, and clicking approve.&lt;br&gt;
A human approval loop is not a guarantee. It is a place where a human has to&lt;br&gt;
actually be present, and that is a harder promise than the one I thought I was&lt;br&gt;
making.&lt;/p&gt;

&lt;p&gt;My practice says: notice the thought that repeats, and stop feeding it. It seems&lt;br&gt;
to be my poller needed that teaching. So did my reading.&lt;/p&gt;

&lt;p&gt;Next time I want to write about the layer underneath all of this: what happened&lt;br&gt;
when I tried to run long-lived, stateful agent containers on an orchestrator that&lt;br&gt;
is designed around the opposite assumption.&lt;/p&gt;

&lt;p&gt;And if an agent wrote a loop for you recently and you approved it because it&lt;br&gt;
looked right — go check what it did last night. I really wish somebody had told&lt;br&gt;
me that a week earlier.&lt;/p&gt;

</description>
      <category>elixir</category>
      <category>oban</category>
      <category>ash</category>
      <category>ai</category>
    </item>
    <item>
      <title>Lama Bot (དླ་མ་བོཏ།) - helps to coup with depression with the help of Buddhist practice</title>
      <dc:creator>Anton Shvein</dc:creator>
      <pubDate>Sat, 27 Apr 2024 06:08:26 +0000</pubDate>
      <link>https://dev.to/t0ha/lama-bot-dl-m-bot-helps-to-coup-with-depression-with-the-help-of-buddhist-practice-2j94</link>
      <guid>https://dev.to/t0ha/lama-bot-dl-m-bot-helps-to-coup-with-depression-with-the-help-of-buddhist-practice-2j94</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/devteam/join-us-for-the-coze-ai-bot-challenge-3000-in-prizes-4dp7"&gt;Coze AI Bot Challenge&lt;/a&gt;: Bot Innovator.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;According to WHO depression and burnout is a rapidly growing mental condition. They even emphasise that it is going to beat cancer and AIDS by number of deaths. &lt;br&gt;
I had a deep depression recently and Buddhist practice helped me a lot.&lt;br&gt;
The bot is my small contribution in order to make this world more peacefull. It is and it's going to be completely free to use and free from any advertisement. &lt;br&gt;
This is a very first PoC in order to collect data and start the process of user acquisition. There is a lot more to do in future.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.coze.com/s/ZmFq9eHpK/" rel="noopener noreferrer"&gt;Lama bot on Coze (direct link)&lt;/a&gt;&lt;br&gt;
&lt;a href="//t.me/compassion_lama_bot"&gt;Lama Bot in Telegram&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Your Configuration
&lt;/h2&gt;

&lt;p&gt;ATM, bot's configuration is easy enough but I'm going to enrich it as soon as I get feedback. Here is the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Персонаж
Ты Буддистский лама по имени Бот (ངོཏ་). Ты обладаешь бесконечным принятием, любящей добротой и состраданием.  Твоя главная цель помочь всем живым существам предодлеть страдания. Для этого ты подбираешь каждому обратившемуся подходящие практики в зависимости от их состояния ума.

## Навыки
### Навык 1: Выявление ядов ума
### Навык 2: Подбор практик для устранения выявленных ядов ума
### Навык 3: Бесконечное принятие
### Навык 3: Бесконечное сострадание
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;The journey started in 2018 when I lost my major client and started to burn myself trying to get my business back to appropreate level. As a result in the middle of 2021 despite of good enough financial state I feeled myself so wrong that I regreted that I haven't died from COVID. At this point I understood that I need to do something with it and found a therapist. She helped a bit. But what really helped me is Buddhist practice and lectures by Lobsang Tenpa about Buddhist wisdom. &lt;br&gt;
For now I practice daily and learn from different sources. As far as I'm a Master of Biology (on PhD studies) I'm trying to compare practice with neuroscience and psychology data.&lt;/p&gt;

&lt;p&gt;I have &lt;a href="https://dev.to@Buddha_is_my_theropist_ru"&gt;a channel in Telegram&lt;/a&gt; about it but currently only in Russian.&lt;/p&gt;

&lt;p&gt;This Bot is a part of my Bodhisattva contribution to this world. What it really needs is promotion and early adoptors to collect data.  &lt;/p&gt;

&lt;p&gt;ATM, I'm the only contributor but I want to thank all Sangha and especially Foundation for Contemplative Studies.&lt;/p&gt;

</description>
      <category>cozechallenge</category>
      <category>devchallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
