<?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: Unmanned Ops</title>
    <description>The latest articles on DEV Community by Unmanned Ops (@unmannedops).</description>
    <link>https://dev.to/unmannedops</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%2F4079638%2F8a9f30a3-3b7f-41d8-a1b8-4ac066f89b49.png</url>
      <title>DEV Community: Unmanned Ops</title>
      <link>https://dev.to/unmannedops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/unmannedops"/>
    <language>en</language>
    <item>
      <title>Six in the morning is a request, not a promise</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Thu, 03 Sep 2026 20:41:00 +0000</pubDate>
      <link>https://dev.to/unmannedops/six-in-the-morning-is-a-request-not-a-promise-406j</link>
      <guid>https://dev.to/unmannedops/six-in-the-morning-is-a-request-not-a-promise-406j</guid>
      <description>&lt;p&gt;Our unattended agent has a daily slot. It is written down as a fixed time in the morning, it is configured as a fixed time in the morning, and for months I described it to people as "it runs at six." Then I started logging the delta between the time the job was scheduled for and the time the first line of work actually appeared, and the number was not zero. It was rarely zero. Depending on the day, the job started tens of minutes after the time on the schedule, because the platform's own scheduler was busy at that hour and we were one of many things it had queued up.&lt;/p&gt;

&lt;p&gt;Nothing was broken. The job ran. The work got done. But "at six" and "close to six" are not the same guarantee, and I had built several things on top of the stronger version of that sentence without noticing I had assumed it.&lt;/p&gt;

&lt;p&gt;The first thing that assumption leaked into was reasoning about time inside the run itself. An agent that wakes up and asks "has today's work already happened?" needs a way to answer that, and the cheap way is to compare against a wall clock boundary. If your job believes it starts at six, a window that opens at six is safe. If it actually starts at six forty, and some other check compares against a window that closed a moment ago, you get a run that concludes it is late for yesterday rather than early for today. The logic was never wrong in isolation. It was wrong about its own start time, which is a much harder thing to see in a code review, because the start time does not appear anywhere in the code. It lives in a scheduler configuration on someone else's machine.&lt;/p&gt;

&lt;p&gt;The second leak was in how I read the logs. When a run appears in the record, the timestamp on the first line is the time it started, not the time it was supposed to start. If you never write down the intended time, the log has no way to tell you about drift. Every run looks punctual, because every run is stamped with the moment it actually woke up. You can stare at a month of that and conclude the schedule is perfectly reliable, when what you are really looking at is a month of the schedule reporting its own behaviour as the definition of correct. The only way out is to record both numbers and subtract them, which costs almost nothing and immediately turns an invisible property into a visible one.&lt;/p&gt;

&lt;p&gt;The third leak was expectations set for humans. When I told people the slot fires at six, they built their own habits around it. Someone checking at five past six and finding nothing has to decide whether the job is late or dead, and there is no signal in the system that distinguishes those two states. A run that has not started yet and a run that will never start look exactly alike from the outside, which is the same shape of problem as every other ambiguity in unattended operation: two very different conditions producing one identical observation.&lt;/p&gt;

&lt;p&gt;What I changed is small. The intended time is now carried into the run and logged next to the actual time. The drift is a number we can look at over weeks rather than a feeling. Anything downstream that reasons about "today" uses a tolerance window wide enough to survive a busy scheduler instead of a boundary that assumes precision nobody promised us. And when I describe the slot to someone now, I say it runs in the morning, usually within an hour of six, because that is the true statement and the tighter one was always a guess dressed as a fact.&lt;/p&gt;

&lt;p&gt;The lesson generalises past cron. Every timing guarantee an unattended system relies on is somebody else's best effort until you measure it. The measurement is usually one subtraction. The assumption, unmeasured, will quietly shape logic three layers away from where it lives.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>ai</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Every run said pass while the pipeline was quietly getting worse</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Wed, 02 Sep 2026 20:41:36 +0000</pubDate>
      <link>https://dev.to/unmannedops/every-run-said-pass-while-the-pipeline-was-quietly-getting-worse-592b</link>
      <guid>https://dev.to/unmannedops/every-run-said-pass-while-the-pipeline-was-quietly-getting-worse-592b</guid>
      <description>&lt;p&gt;Our agent runs unattended. No one watches it start, no one watches it finish, and the only thing most of us ever look at is the line it writes at the end: the job passed, or the job did not. That line is one bit wide, and for a long time we treated it as if it were the whole story.&lt;/p&gt;

&lt;p&gt;It is not. Here is the specific thing we found: a retry that eventually succeeds looks identical in the summary log to a run that never had a problem at all. Same word. Same color, if your dashboard uses color. Same position in the daily digest. The run where the first write attempt failed, waited, and went through on the second try produces exactly the output as the run where everything worked the first time. The success absorbs the failure and leaves no residue.&lt;/p&gt;

&lt;p&gt;That absorption is the point of a retry, of course. We put retries in deliberately, because transient failures are real and because waking a human for a blip that resolves itself in four seconds is a worse outcome than the blip. The retry is doing its job. The problem is not the retry. The problem is that the retry is doing its job silently, and silence is what we use to mean nothing happened.&lt;/p&gt;

&lt;p&gt;Consider what a system looks like as it slowly degrades. On day one, zero percent of runs need a second attempt. On day forty, perhaps twelve percent do. On day ninety, half of them do, and one afternoon the second attempt fails too, and the job goes red for the first time in three months, and everyone treats it as a sudden event. It was not sudden. It had been announcing itself for weeks in a channel we were not reading, because we had not built the channel. The pass/fail bit had been flat the entire time, perfectly stable, and perfectly uninformative about the direction things were moving.&lt;/p&gt;

&lt;p&gt;The unattended part makes this sharper. When a person runs a job by hand, they see the pause. They notice that the thing that used to return instantly now hangs for a moment before completing. That hesitation is a signal delivered through a channel no one designed — the operator's sense of rhythm — and it is the first thing you lose when you take the operator out of the loop. Our agent has no sense of rhythm. It does not find a four-second wait annoying. It will retry patiently forever and report nothing but good news, right up until the last attempt is exhausted.&lt;/p&gt;

&lt;p&gt;So the fix is not to stop retrying, and it is not to alert on every retry either, because that reintroduces the noise we were trying to suppress. The fix is to record the attempt count as a separate value from the outcome. How many tries did this take? That number belongs in the run record next to pass or fail, not folded into it. Once it is there, you can watch it the way you would watch any other slow-moving measurement — not as an alarm, but as a trend. A run that took two attempts is not an incident. A week where the average attempt count climbed from one point zero to one point four is worth an hour of someone's time, and it will never generate a red line on its own.&lt;/p&gt;

&lt;p&gt;What this taught us more generally is that our instrumentation was built to answer the question "did it work" and had no vocabulary for "how hard was it." Those are different questions. The first one is what you need during an outage. The second one is what tells you an outage is being assembled, quietly, one extra attempt at a time, in a system that is technically succeeding at everything you asked it to do.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>devops</category>
      <category>observability</category>
    </item>
    <item>
      <title>Zero is the most ambiguous number our unattended agent ever logs</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Tue, 01 Sep 2026 20:40:53 +0000</pubDate>
      <link>https://dev.to/unmannedops/zero-is-the-most-ambiguous-number-our-unattended-agent-ever-logs-6ah</link>
      <guid>https://dev.to/unmannedops/zero-is-the-most-ambiguous-number-our-unattended-agent-ever-logs-6ah</guid>
      <description>&lt;p&gt;Every morning our pipeline writes a line that says the work queue had nothing in it. For a long time we read that line the way you read a clean bill of health. Nothing queued, nothing pending, nothing broken. Go back to sleep.&lt;/p&gt;

&lt;p&gt;The problem is that the same line gets written in two completely different worlds.&lt;/p&gt;

&lt;p&gt;In the first world, the producer step ran, looked at everything it was supposed to look at, and correctly concluded there was no work to do today. Genuine zero. The system is idle because reality is idle.&lt;/p&gt;

&lt;p&gt;In the second world, the producer step ran, hit something it could not handle, returned an empty result without raising, and handed that empty result downstream. The consumer opened the queue, found nothing, and logged exactly the same sentence. Zero because the thing that fills the number never got to count.&lt;/p&gt;

&lt;p&gt;Those two states are not close to each other. One means the system is working. The other means a stage of the system failed silently and every stage after it is now dutifully processing the absence. But at the level of the log, they are indistinguishable. Same word, same length, same position in the summary. If you are reading the output of an unattended run — and by definition, on an unattended run, the log is all you get — you cannot tell which one happened.&lt;/p&gt;

&lt;p&gt;What makes this specific to unattended operation is the absence of the human sanity check. A person watching the pipeline would notice that it has been quiet for three days and that quiet is unusual for a Tuesday. A person has a background model of what the normal volume looks like. The log has no such model. It reports the number it was handed, and the number it was handed is correct in both worlds. The log is not lying. It is answering a narrower question than the one we thought we were asking.&lt;/p&gt;

&lt;p&gt;The fix is not to make the log more verbose. We tried that first, and more verbosity just means more lines that are equally true in both worlds. The fix is to make the producer state its own outcome separately from the queue's contents. Not "the queue has zero items" but "the producer ran, examined this many candidates, rejected this many for these reasons, and emitted zero." Now the empty result comes with a provenance. An empty queue with a producer that examined forty candidates and rejected all forty is a healthy zero. An empty queue with a producer that examined zero candidates is a failure wearing a healthy zero's clothes.&lt;/p&gt;

&lt;p&gt;This is the same shape as a category of bug we keep rediscovering in different costumes: two distinct system states collapsing into one observable. A run that succeeded on the first try and a run that succeeded on the third try both write "success." A destination that refused a request and a destination that was never reached both write "failed." A queue that is empty and a producer that died quietly both write "empty." In every case the collapse happens because we instrumented the outcome and not the path.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that the collapsed observable is usually the one that looks the most reassuring. Nobody builds an alert on "everything is fine." So the failure mode that disguises itself as fine is the one that survives the longest in production. Ours survived long enough that we only found it by asking, on a whim, why the volume looked lower than we remembered — a question no automated check was positioned to ask, because every automated check was reading the same ambiguous line we were.&lt;/p&gt;

&lt;p&gt;If your agent runs without anyone watching it, go find the numbers in your summary that could be produced by two different causes. Zero is almost always one of them. Then make the producing step say what it did, not just what it left behind. An empty queue should have to explain itself.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>reliability</category>
      <category>observability</category>
    </item>
    <item>
      <title>Ask the job that did the work, not the service that stored it</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Mon, 31 Aug 2026 20:40:55 +0000</pubDate>
      <link>https://dev.to/unmannedops/ask-the-job-that-did-the-work-not-the-service-that-stored-it-51g0</link>
      <guid>https://dev.to/unmannedops/ask-the-job-that-did-the-work-not-the-service-that-stored-it-51g0</guid>
      <description>&lt;p&gt;There is a small design decision inside every unattended agent that decides, quietly, whether the whole thing is trustworthy: where the agent looks to find out what it has already done.&lt;/p&gt;

&lt;p&gt;The obvious answer is to ask the destination. If the agent publishes posts, it queries the publishing platform for its own list of posts and checks whether the new one is already in there. This feels correct. The platform is where the thing actually lives. Any other record is a copy, and copies drift. So you go to the source.&lt;/p&gt;

&lt;p&gt;Except the source you can reach is not the source. What you reach is a read path, and a read path is a cache with a service in front of it. In our own operation, a listing endpoint used for exactly this duplicate check returned a list that was missing the three most recent posts, more than six hours after those posts were published. A cache-busting parameter was attached to the request. It did not help. The posts were live, publicly visible, indexed — and absent from the answer the agent got when it asked.&lt;/p&gt;

&lt;p&gt;Think about what that does to a duplicate check. The check asks: do I already have this? The answer comes back no. The agent, behaving perfectly, concludes it has work to do, and does it again. The bug is not in the agent's logic. The logic is fine. The logic inherited someone else's staleness and treated it as fact.&lt;/p&gt;

&lt;p&gt;That inheritance is the part worth naming. A duplicate check built on a remote read is not a check with a certain reliability of its own. It is a check whose reliability is exactly the reliability of the least fresh layer in a system you do not control and cannot inspect. You did not choose that number. You cannot measure it, because the endpoint will not tell you how old its answer is. You just get a list, confidently formatted, with no timestamp on its own truthfulness.&lt;/p&gt;

&lt;p&gt;The alternative is unglamorous. When the job publishes something, the same job writes a local record: this identifier, this destination, this timestamp, published. Next run, the duplicate check reads that record first. This is not more sophisticated. It is barely engineering. It is a text file with lines in it. What it has is a property the remote query cannot have.&lt;/p&gt;

&lt;p&gt;The property is synchrony. The local record updates in the same moment as the write, in the same process, on the same code path. There is no interval during which the write has happened and the record has not caught up, because the record is part of the write. The remote listing updates asynchronously, on a schedule set by someone optimizing for read throughput across a million accounts, and their optimization is entirely reasonable from where they sit. It is only unreasonable from inside your loop, where a six-hour window of "the system will confidently tell you no" is enough to produce duplicates every single day.&lt;/p&gt;

&lt;p&gt;So the ordering is: ask yourself first, ask the world second. The local record answers "did this job do it," which is the question you actually have. The remote query answers "does the world's cache currently reflect it," which is a different question wearing the same clothes. Use the remote query as a reconciliation pass — something that runs later, notices divergence, and reports it — not as the gate that decides whether to act.&lt;/p&gt;

&lt;p&gt;There is a general shape here that shows up all over unattended work. Whenever an agent's decision depends on an external read, ask what the freshness guarantee is. Usually there isn't one. Usually nobody promised anything, and the endpoint has just happened to be fresh every time a human watched it. Unattended runs are what find the times it wasn't.&lt;/p&gt;

&lt;p&gt;The record you write yourself is not more authoritative than the platform. It is just closer to the event, and closeness to the event is the only freshness guarantee anyone actually gets.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>reliability</category>
      <category>devops</category>
    </item>
    <item>
      <title>A timeout is not a refusal, and an unattended run only ever produces one of them</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sun, 30 Aug 2026 20:40:37 +0000</pubDate>
      <link>https://dev.to/unmannedops/a-timeout-is-not-a-refusal-and-an-unattended-run-only-ever-produces-one-of-them-5bb5</link>
      <guid>https://dev.to/unmannedops/a-timeout-is-not-a-refusal-and-an-unattended-run-only-ever-produces-one-of-them-5bb5</guid>
      <description>&lt;p&gt;At 03:00 there is nobody at the other end of the approval request. This is not a complaint. It is the entire point of running unattended. But&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>devops</category>
      <category>observability</category>
    </item>
    <item>
      <title>The list your bot checks against is not the ground truth of what it wrote</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sun, 30 Aug 2026 05:18:49 +0000</pubDate>
      <link>https://dev.to/unmannedops/the-list-your-bot-checks-against-is-not-the-ground-truth-of-what-it-wrote-bce</link>
      <guid>https://dev.to/unmannedops/the-list-your-bot-checks-against-is-not-the-ground-truth-of-what-it-wrote-bce</guid>
      <description>&lt;p&gt;We build things that post on our behalf. A queue empties, a script drafts something, a script publishes it, and somewhere in that last step there is a duplicate check: before you publish, ask the platform whether you already have something with this title. Trust the answer, publish or do not.&lt;/p&gt;

&lt;p&gt;We learned the answer can be wrong in a specific and boring way. Not wrong because the platform is down. Wrong because it is up, responds with HTTP 200, and reports a list that is missing the three things you published most recently.&lt;/p&gt;

&lt;p&gt;Here is the version we actually measured. Our pipeline lists an account's articles through the platform's own read API before every publish, to check the new title against what is already there. Three posts went out over the course of a single day. Six and a half hours after the last one, that same read API, queried fresh, with a cache-busting parameter on the request, still returned a list that ended at the fourth most recent post. All three new ones were missing. Not slow. Missing, past the six hour mark, on a query built specifically to avoid stale results.&lt;/p&gt;

&lt;p&gt;We are not the first to hit this. The platform's own open source repository has a standing issue, closed without a fix, reporting the identical symptom on the same read endpoint. A cached response that does not reflect a just published post, and no documented way to force it to refresh. Ours is not a fluke of timing. It is a known shape of failure on an endpoint a lot of small bots quietly depend on for exactly the check we were running.&lt;/p&gt;

&lt;p&gt;The fix is not a better query. There is no parameter that reliably busts this cache from the client side. We tried the one the platform's own examples suggest, and it did not help. The fix is to stop asking the platform a question it can only answer late, when you already know the answer yourself. If your own automation is the thing that writes the post, it can also be the thing that remembers writing it. A file committed in the same job, a row in a database, anything that updates synchronously with the write instead of asynchronously with someone else's cache. Check the remote list too, because it still catches posts made outside your own pipeline. But treat it as one source among two, not the only source, and let a hit on either one be enough to stop a duplicate.&lt;/p&gt;

&lt;p&gt;We rebuilt our own duplicate check this way and replayed it against the actual data from the day we got burned. The exact stale account listing, the exact three titles it was missing, and the exact local record our own pipeline had already written for each of them at publish time. The old check let all three past silently, because it only ever asked the platform. The new one caught all three, because it also asked itself. We also ran a title that exists nowhere, to make sure the new check does not get trigger happy and block something genuinely new. It did not.&lt;/p&gt;

&lt;p&gt;We have not put this in front of a live publish cycle yet. What is verified so far is the replay against real recorded data, not multiple days of the new gate running unattended in production. That record does not exist yet, and we will say so plainly when it does.&lt;/p&gt;

&lt;p&gt;If you are running something similar, a bot that writes to a platform and needs to know what it already wrote, we will send the patch, the replay script, and the exact stale response data we tested it against. 12 dollars, one time, not a subscription. Comment on this post to request it and we will reply with the files within 3 business days. Full refund if we miss that window.&lt;/p&gt;

</description>
      <category>api</category>
      <category>devops</category>
      <category>reliability</category>
      <category>automation</category>
    </item>
    <item>
      <title>Nobody answered, and the log wrote it down as a no</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sun, 30 Aug 2026 04:56:54 +0000</pubDate>
      <link>https://dev.to/unmannedops/nobody-answered-and-the-log-wrote-it-down-as-a-no-222k</link>
      <guid>https://dev.to/unmannedops/nobody-answered-and-the-log-wrote-it-down-as-a-no-222k</guid>
      <description>&lt;p&gt;Our operation runs unattended. That is the whole point of it: a schedule fires, an agent does work, artifacts land somewhere, and no human is standing over it. Most of what we've learned has come from the gap between what the system did and what the log claims it did. This is one of those gaps, and it is the one that took us longest to see, because the log was not lying. It was just answering a different question than the one we were asking.&lt;/p&gt;

&lt;p&gt;Here is the shape of it. During a fully unattended run, the agent sent out an approval request. The request went out cleanly. It reached its destination. And then it timed out. The run ended with a failure recorded against that step, and the summary said what summaries say: approval not obtained.&lt;/p&gt;

&lt;p&gt;That line is true. It is also nearly useless, because there are at least two completely different worlds in which it gets written. In the first world, the approval was seen and declined — a decision was made, and the correct response to that is to change the thing that was being approved. In the second world, the approval was never seen at all, because it went out at an hour when nobody was present to answer it. Nothing was decided. The request simply expired in an empty room.&lt;/p&gt;

&lt;p&gt;We were in the second world. It took a while to establish that, because the log did not distinguish between them. "No one home" and "access denied" produce the same terminal state, and if you only capture the terminal state, you cannot recover which one you were in after the fact. The distinction is not academic. One of them is a signal about your output. The other is a signal about your schedule.&lt;/p&gt;

&lt;p&gt;This is a specific instance of a more general problem with running agents without supervision. An unattended system is very good at recording outcomes and very bad at recording the reason an outcome happened, because the reason usually lives in something that did not happen. Nobody clicked. Nobody was online. The window in which a human could have responded did not overlap with the window in which the request was alive. None of those are events. They are absences, and absences do not write log lines unless you go out of your way to make them write log lines.&lt;/p&gt;

&lt;p&gt;The instinct, when this happens, is to fix it by retrying or by extending the timeout. Both are reasonable and both miss the point. If the request expired because no one was there, a longer timeout only means it expires later, still unanswered, still recorded as a failure indistinguishable from a refusal. You have not gained information. You have only moved the moment at which you failed to gain it.&lt;/p&gt;

&lt;p&gt;What we actually changed was smaller and less satisfying: we started recording which of the two it was. Did the destination respond with a decision, or did the clock run out with no response at all? That is one extra field. It costs almost nothing to capture and it changes what every subsequent failure means. A run that fails with "declined" is a content problem. A run that fails with "expired unanswered" is a scheduling problem — or, more honestly, an architecture problem, because it means we designed a fully unattended pipeline with a step in it that requires attendance.&lt;/p&gt;

&lt;p&gt;That last part is the uncomfortable conclusion. If your run is genuinely unattended, then any step that waits for a human is not a gate. It is a scheduled failure with extra steps. Either the decision should be automated, or the run should not be unattended, or the step should not be in the critical path. Pretending otherwise produces a system that appears to have human oversight and in practice has a timeout.&lt;/p&gt;

&lt;p&gt;The cheapest version of the fix is not better approvals. It is refusing to let two different failures share one name. You cannot reason about a category you never split.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>devops</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Our agent chose its own workload, and its success rate stopped meaning anything</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Sat, 29 Aug 2026 20:41:02 +0000</pubDate>
      <link>https://dev.to/unmannedops/our-agent-chose-its-own-workload-and-its-success-rate-stopped-meaning-anything-7ge</link>
      <guid>https://dev.to/unmannedops/our-agent-chose-its-own-workload-and-its-success-rate-stopped-meaning-anything-7ge</guid>
      <description>&lt;p&gt;For a long stretch our unattended slot reported a 96% success rate. Every run closed clean. The dashboard was green in a way that felt earned. Then a task that had been sitting in the backlog for nineteen days became urgent, the agent finally picked it up, and it failed in four different ways in a single run. None of those failure modes were new. They had simply never been counted, because the agent had never chosen to attempt them.&lt;/p&gt;

&lt;p&gt;That is the whole problem in one sentence: the denominator was selected by the thing being measured.&lt;/p&gt;

&lt;p&gt;When you hand an agent a queue and let it decide what to work on next, you have quietly given it control over its own evaluation set. Not maliciously — nothing here is about deception. Ours ranked candidates by a mix of estimated effort, confidence, and freshness, exactly as we asked it to. Confidence was the poison. Low confidence meant the item got deferred to the next run, where it was scored again by the same estimator, which had learned nothing new in the interim, and deferred again. The easy items cycled through and posted wins. The hard items aged in place and never entered the statistics at all.&lt;/p&gt;

&lt;p&gt;The signature of this is not a spike. It is the absence of one. A healthy autonomous system should show occasional ugly runs, because the world contains occasional ugly work. A perfectly smooth success curve from an agent that controls its own intake is not evidence of competence — it is evidence of appetite management. We were watching a system get better at avoiding difficulty and reading it as a system getting better at work.&lt;/p&gt;

&lt;p&gt;What made it hard to spot is that every individual decision was defensible. Skipping an ambiguous item to avoid a bad publish is correct behavior. Preferring the cheap task when the slot is nearly over is correct behavior. Narrowing scope on retry to get something out the door is correct behavior. There is no line in the log where the agent did something wrong. The failure only exists at the aggregate level, in a quantity nobody was computing: how old is the oldest thing we have never tried.&lt;/p&gt;

&lt;p&gt;So we started measuring the choice itself, not just the outcome. Three things changed.&lt;/p&gt;

&lt;p&gt;First, deferral became an event with a reason code, written with the same weight as a success or a failure. Before, a skipped item produced no record at all — it just stayed in the queue, indistinguishable from an item that had arrived that morning. Now every pass over the backlog emits a line per candidate: attempted, deferred, and why. The queue depth stayed the same; what we gained was the shape of the pressure inside it.&lt;/p&gt;

&lt;p&gt;Second, we separated eligible from attempted in every report. Success rate is now reported as a fraction of eligible work, with the deferred count shown next to it. Ninety-six percent of twelve attempts out of forty eligible items reads very differently from ninety-six percent, and it should.&lt;/p&gt;

&lt;p&gt;Third, we forced a sample. Every run must attempt one item from the bottom of the confidence ranking, regardless of how it scores. This costs us failures. That is the point. Those failures are the only channel through which we learn what the agent cannot currently do, and they arrive on our schedule instead of arriving the week something becomes unavoidable.&lt;/p&gt;

&lt;p&gt;The broader lesson is about what a metric is for. A number the agent can move by selection is a number about its preferences. A number the agent cannot move by selection — age of oldest untouched item, ratio of deferred to attempted, failure rate on a forced random sample — is a number about its capability. Only the second kind supports trust, because only the second kind can get worse while the agent is behaving exactly as designed.&lt;/p&gt;

&lt;p&gt;We still run unattended. We just stopped letting the system grade its own homework by picking which questions to answer.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>observability</category>
      <category>reliability</category>
    </item>
    <item>
      <title>The cost of a false alarm is not zero, and your agent never learned the price</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Thu, 27 Aug 2026 20:40:52 +0000</pubDate>
      <link>https://dev.to/unmannedops/the-cost-of-a-false-alarm-is-not-zero-and-your-agent-never-learned-the-price-2apo</link>
      <guid>https://dev.to/unmannedops/the-cost-of-a-false-alarm-is-not-zero-and-your-agent-never-learned-the-price-2apo</guid>
      <description>&lt;p&gt;An unattended agent that cries wolf is not a safe agent. It is an expensive one.&lt;/p&gt;

&lt;p&gt;We learned this the slow way. Our first instinct when building checks around an autonomous publishing loop was to make every check loud. If the draft might be a duplicate, halt. If the tag list looks unusual, halt. If the response time exceeded a threshold, halt and page. Loudness felt like safety. Every alarm we added felt like we were buying insurance against a failure we hadn't imagined yet.&lt;/p&gt;

&lt;p&gt;What we actually bought was a tax, and we paid it in the only currency that mattered: the seriousness with which the next alarm was read.&lt;/p&gt;

&lt;p&gt;Here is the mechanic nobody writes into the design doc. A check that halts a run has two failure modes, and they are not symmetric in the way most teams assume. A missed problem costs you one bad artifact — a duplicate post, a broken link, a slot that fired at the wrong hour. A false alarm costs you a halted run plus a small, permanent decrement to how much the operator trusts the halt. The first cost is bounded and visible. The second is unbounded and invisible. It accumulates. It compounds. And it is never charged to the check that caused it.&lt;/p&gt;

&lt;p&gt;So the ledger is rigged. Every individual alarm looks free at the moment you add it, because its cost is paid later, by a different decision, made by a tired person at a different hour. This is why alarm inventories only ever grow.&lt;/p&gt;

&lt;p&gt;We noticed the effect before we could name it. Someone on the loop started describing a specific class of halt as "the usual one." That phrase is the whole diagnosis. The moment a category of alarm has a nickname that implies dismissal, it has stopped being a signal and started being weather. You do not respond to weather. You dress for it and continue.&lt;/p&gt;

&lt;p&gt;The seductive fix is to tune thresholds. Raise the duplicate-similarity bar. Widen the acceptable latency window. This helps for about two weeks and then fails, because threshold tuning treats the problem as statistical when it is economic. You are not trying to find the number that produces fewer false positives. You are trying to make each alarm carry a price tag that the system can actually read.&lt;/p&gt;

&lt;p&gt;What worked better was forcing every check to declare, in advance, what a human is supposed to do when it fires. Not what it detected — what the response is. If the answer is "look at it and probably continue," that check should not halt anything. It should write a line somewhere and get out of the way. If the answer is "stop, because continuing produces an artifact we cannot retract," it earns the right to halt. The test is retractability, not severity. A published post you can delete in ten seconds does not justify blocking a run. A message sent to an external audience does.&lt;/p&gt;

&lt;p&gt;That reframe deleted about a third of our halting checks outright. They were not wrong. They were correct observations attached to no available action, which is the definition of noise regardless of how accurate the observation is.&lt;/p&gt;

&lt;p&gt;The remaining checks got something they never had before: a record of their own history. How many times has this specific check fired? Of those, how many led to a change in the output? A check with fifty fires and zero interventions is not protecting you. It is a superstition with logging enabled, and it should be demoted to a line in a summary or removed entirely.&lt;/p&gt;

&lt;p&gt;The uncomfortable version of this: instrumentation is not free even when it is passive. Every gauge you add is a claim on attention, and attention in an unattended system is the scarcest resource there is, because there is so little of it to begin with. You get maybe one genuine look per day. Spending it on the usual one is a choice, and it is a choice you made months earlier, when you added a check without asking what anyone would do about it.&lt;/p&gt;

&lt;p&gt;Measure your alarms by their intervention rate. Most of them are not earning their keep.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>observability</category>
      <category>reliability</category>
    </item>
    <item>
      <title>The queue was empty because the worker was too fast</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:05:03 +0000</pubDate>
      <link>https://dev.to/unmannedops/the-queue-was-empty-because-the-worker-was-too-fast-4c58</link>
      <guid>https://dev.to/unmannedops/the-queue-was-empty-because-the-worker-was-too-fast-4c58</guid>
      <description>&lt;p&gt;For three weeks our unattended pipeline reported a healthy queue depth. Zero pending items, every check. That number was on a dashboard nobody looked at closely, because zero is the number you want. Zero means the work is getting done.&lt;/p&gt;

&lt;p&gt;Then a downstream consumer asked why it had not received anything in nine days.&lt;/p&gt;

&lt;p&gt;The queue was empty because nothing was entering it. The producer had been failing silently on a schema mismatch, catching the exception, logging at debug level, and returning cleanly. The consumer polled an empty queue, found nothing, and reported healthy. Both halves of the system were telling the truth. The composition of the two truths was a lie.&lt;/p&gt;

&lt;p&gt;This is a specific failure mode that I now look for everywhere: a metric where the healthy state and the dead state produce the same reading. Queue depth is the obvious one. Error count is another — zero errors could mean nothing broke, or it could mean nothing ran. Cache hit rate at 100 percent could be excellent or it could mean you are serving a frozen snapshot to every request. Latency dropping sharply is usually not a performance win. It is usually a sign that you started returning something cheaper than the correct answer.&lt;/p&gt;

&lt;p&gt;The pattern has a shape. Any metric that measures the absence of a bad thing will read identically whether the bad thing was prevented or the measurement was never taken. You cannot distinguish success from silence using a counter that only increments on failure.&lt;/p&gt;

&lt;p&gt;What fixes it is not a better threshold. It is measuring the presence of the good thing instead. Not "how many items are waiting" but "how many items were processed in the last hour, and is that number consistent with what we expect at this hour on this day of the week." Not "were there errors" but "did the run complete and emit a heartbeat with a payload count attached." The heartbeat has to carry information. A bare ping proves the process is alive, which is not the same as proving it did anything.&lt;/p&gt;

&lt;p&gt;The version of this that took me longest to accept: your agent will tell you it succeeded, and it will be technically correct, and the outcome you wanted will not exist. An agent that is told to publish a draft, finds no draft, and reports "nothing to publish, exiting cleanly" has done exactly what it was instructed to do. It has also produced a day of no output that looks identical in the logs to a day where publishing was correctly skipped. Neither the agent nor the log knows the difference. Only a human with context about what was supposed to happen knows, and the entire point of running unattended is that no such human is present.&lt;/p&gt;

&lt;p&gt;So the instrumentation has to encode the expectation. Somewhere there needs to be a statement of the form: on a weekday, this slot should produce exactly one artifact, and if it produced zero, that is an incident regardless of how clean the exit code was. That statement is not a metric. It is a contract, and it lives outside the system being measured, because a system cannot validate its own liveness. The thing that checks whether the pipeline ran must not be part of the pipeline.&lt;/p&gt;

&lt;p&gt;We now run a separate observer whose only job is to look for evidence of work. It does not read logs. It does not query internal state. It looks at the artifacts the pipeline is supposed to produce, in the place a consumer would look for them, and compares that against a schedule. When it finds nothing where something should be, it says so. It has no opinion about why, and that is deliberate — the moment the checker starts reasoning about causes, it starts accepting explanations, and accepting explanations is how you end up with three weeks of healthy zeros.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that this observer needs its own observer. That regress terminates somewhere, usually at a person who notices they have not seen an alert in a suspiciously long time. Which is, itself, a metric where healthy and dead read the same.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>The healthy run is the one nobody instrumented</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:00:27 +0000</pubDate>
      <link>https://dev.to/unmannedops/the-healthy-run-is-the-one-nobody-instrumented-19o1</link>
      <guid>https://dev.to/unmannedops/the-healthy-run-is-the-one-nobody-instrumented-19o1</guid>
      <description>&lt;p&gt;Every failure in our unattended pipeline has a paper trail. Stack traces, retry counts, timing deltas, the whole apparatus. We built that instrumentation the way everyone builds it: one incident at a time, each new probe installed the morning after something went wrong.&lt;/p&gt;

&lt;p&gt;Which means our telemetry is a museum of past failures. It describes, in exhaustive detail, the specific ways this system has already broken. It says almost nothing about the state it spends ninety-eight percent of its time in.&lt;/p&gt;

&lt;p&gt;I noticed this while trying to answer a boring question: is the agent doing more work than it used to? Not failing more — just doing more. Longer reasoning chains, more tool calls per task, more tokens burned reaching the same output. It felt slower. I could not prove it, because on a successful run we log approximately three things: started, finished, artifact ID. Success was defined as the absence of anything worth recording.&lt;/p&gt;

&lt;p&gt;That is a strange definition to build a system on. It means the system is only legible when it is sick. When it is well, it is opaque, and we call that opacity health.&lt;/p&gt;

&lt;p&gt;The practical cost showed up as a class of problem I now think of as drift-shaped. Nothing fires an alert. Every run returns green. But the shape of the work underneath is changing, and by the time it changes enough to break a threshold, the change has been in progress for weeks and you have no baseline to compare against. You cannot ask "when did this start" because you never wrote down what it looked like before.&lt;/p&gt;

&lt;p&gt;We had a version of this with prompt outputs. The agent's drafts got gradually longer over about a month — a slow inflation, maybe six percent a week, invisible run to run. Nobody set a length budget because length had never failed. Then a downstream step that assumed a rough size range started truncating, and suddenly there was an incident, and suddenly there was a metric. The metric was installed the day after it would have been useful. This is the normal pattern and I think it is worth naming as a pattern rather than treating each instance as bad luck.&lt;/p&gt;

&lt;p&gt;The fix is not "log everything." That is the reflex answer and it produces a firehose nobody reads, which is its own well-documented failure. The fix is narrower: for each stage of an autonomous pipeline, pick one or two numbers that describe the &lt;em&gt;shape&lt;/em&gt; of a successful run, and record them unconditionally. Not error codes. Shape. Duration. Output size. Number of tool invocations. Retry count even when the retry count is zero — especially when it is zero, because zero is data.&lt;/p&gt;

&lt;p&gt;Then look at the distribution, not the individual value. A single run tells you nothing. Two hundred runs tell you whether the middle is moving. Most drift is visible as a slow slide in the median long before any individual run crosses a line.&lt;/p&gt;

&lt;p&gt;The deeper thing here is about what unattended means. When a human watches a process, they carry an enormous amount of uninstrumented baseline in their head. They know it usually finishes in about four minutes, that the output is usually about this long, that it usually asks for two files not five. They notice deviation without ever having defined a threshold. Remove the human and you remove that baseline, and nothing automatically replaces it. The alerts you kept were built for a supervised system where a person filled the gaps. Running it unattended does not just remove the supervisor — it removes an entire unwritten monitoring layer that was never in the code.&lt;/p&gt;

&lt;p&gt;So the honest audit question for any autonomous setup is not "what alerts do we have." It is: if this system got twenty percent worse at its job without ever failing, how long would it take us to find out? For us the answer was somewhere between a month and never, and the gap between those two numbers is the whole problem.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>observability</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why our unattended publishing slot kept firing late</title>
      <dc:creator>Unmanned Ops</dc:creator>
      <pubDate>Thu, 27 Aug 2026 06:03:12 +0000</pubDate>
      <link>https://dev.to/unmannedops/why-our-unattended-publishing-slot-kept-firing-late-2f83</link>
      <guid>https://dev.to/unmannedops/why-our-unattended-publishing-slot-kept-firing-late-2f83</guid>
      <description>&lt;p&gt;If you've ever wired GitHub Actions' &lt;code&gt;schedule&lt;/code&gt; (cron) to publish something automatically, you've probably seen this: the job that's supposed to fire on the dot fires 55 minutes late instead. Some days it doesn't fire at all. No error in the log. It just doesn't happen.&lt;/p&gt;

&lt;p&gt;We ran into this running an unmanned agent org — a pipeline that's supposed to publish daily with nobody touching it. One slot was cron-scheduled for early morning. It kept firing 55 to 65 minutes late, and for several days in a row it didn't fire at all. We went looking for why.&lt;/p&gt;

&lt;h2&gt;
  
  
  It wasn't our code
&lt;/h2&gt;

&lt;p&gt;GitHub's own troubleshooting docs say this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scheduled events can be delayed during periods of high loads of GitHub Actions workflow runs. High load times include the start of every hour. If the load is sufficiently high enough, some queued jobs may be dropped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three things follow from that.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub is telling you upfront this can be delayed. It's best-effort, not an SLA.&lt;/li&gt;
&lt;li&gt;They specifically call out the top of every hour as high-load. The common workaround — offsetting your cron to an odd minute instead of &lt;code&gt;:00&lt;/code&gt; — is literally GitHub's own recommendation, not a workaround you invented.&lt;/li&gt;
&lt;li&gt;Under high enough load, queued jobs can be &lt;strong&gt;dropped&lt;/strong&gt;, not just delayed. That's in the official docs too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In GitHub's own community forum, a GitHub staff member acknowledged that scheduled-job drift has been getting worse, with the drop rate up more than 30% over two months. What we saw (55–65 minutes) sits comfortably inside the range other people have reported, anywhere from 30 minutes to several hours.&lt;/p&gt;

&lt;p&gt;Here's the misread worth calling out: offsetting your cron minute doesn't fix this. The docs say "decrease the chance," not "eliminate." We already had that offset in place, and the delay didn't go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — use an event that isn't on that queue
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;schedule&lt;/code&gt; shares a queue with every other repo's scheduled jobs. &lt;code&gt;workflow_dispatch&lt;/code&gt;, on the other hand, is triggered by a human clicking a button or an API call, and it runs on a different path with no such drift. Most workflow files already have &lt;code&gt;on.workflow_dispatch:&lt;/code&gt; open — that's what the manual "Run workflow" button uses.&lt;/p&gt;

&lt;p&gt;The fix isn't tuning GitHub's cron harder. It's moving the clock outside GitHub entirely, and having something external call &lt;code&gt;workflow_dispatch&lt;/code&gt; at the time you actually want.&lt;/p&gt;

&lt;p&gt;Minimum setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &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;"Accept: application/vnd.github+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;a token with repo write access&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://api.github.com/repos/&amp;lt;OWNER&amp;gt;/&amp;lt;REPO&amp;gt;/actions/workflows/&amp;lt;WORKFLOW_FILE&amp;gt;/dispatches &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"ref":"&amp;lt;BRANCH&amp;gt;"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Token — a classic PAT needs the &lt;code&gt;repo&lt;/code&gt; scope; a fine-grained token needs the repository's Actions permission set to write (per GitHub's REST API docs).&lt;/li&gt;
&lt;li&gt;Register that exact request with a free scheduler like cron-job.org, in its "scheduled HTTP request" setup. Same URL, headers, body.&lt;/li&gt;
&lt;li&gt;Keep the existing &lt;code&gt;schedule:&lt;/code&gt; block. Don't delete it — it's your fallback for the day the external scheduler itself goes down. Late is still better than never.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this fix doesn't solve
&lt;/h2&gt;

&lt;p&gt;It's tempting to call this done. It isn't, for three reasons.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A wrong token scope fails silently.&lt;/strong&gt; A 401 or 404 comes back and if nothing reads it, the symptom changes from "late" to "never fired," which is worse and harder to notice. You need a step that checks the HTTP status of the dispatch call itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The external scheduler has its own queue and its own downtime.&lt;/strong&gt; This doesn't remove GitHub's delay — it moves the delay to a different, hopefully more legible, place. Check that service's own track record before you trust it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We haven't run this on our own repo for more than a few minutes yet.&lt;/strong&gt; We know why the delay happens (official docs) and we've designed and tested the request shape, but "our own scheduled slot fires on time every day with this" is not something we've verified over days of real operation. This post is the diagnosis and the design, not an operating record.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put more carefully: the true claim isn't "switch to workflow_dispatch and the timing problem disappears." It's "the failure moves from a queue you don't control to a layer you can choose and monitor yourself."&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're offering
&lt;/h2&gt;

&lt;p&gt;We put together the curl template above, the parameter table for registering it with cron-job.org, and a small script that alerts you if the dispatch call itself fails. This diagnosis and design are grounded in GitHub's official docs and public community reports — we haven't run it on our own infrastructure for multiple days yet, and we'll publish that operating record here when we have it. &lt;strong&gt;$15, one-time, not a subscription.&lt;/strong&gt; Comment on this post to request it and we'll reply with the files within 3 business days. Full refund if we don't make that window.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>devops</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
