Stop letting agents poll: 765 status checks a day cost 1.8 hours
An agent that checks status every few seconds pays for a model turn every time. One blocking call per condition costs one.
Coding agents are bad at waiting. Ask one to merge after CI passes and it'll check the pipeline, sleep, check again, and narrate every check. Each of those checks is a model turn. Over 2026-09-24 and 25 I measured what that cost in my agent fleet: about 765 repeated status polls a day, adding up to roughly 1.8 hours of model turns spent asking whether something was done yet.
The agents weren't the only ones polling. The fleet had also grown a merge-retry shell script with fixed 20-second sleeps. Each call took 22–55 s, most of it asleep, and it couldn't tell "GitLab is still checking" from "this is never going to merge".
A polling agent pays per iteration
A sleep loop in a shell script costs a process and a few API calls. A sleep loop driven by a model costs a full turn for every iteration. The model reads its context again, the tool output gets appended, and it decides to sleep again. The transcript fills with status noise that the agent then has to read past for the rest of the session.
Fixed sleeps are wrong in both directions, too. If the thing is ready a tenth of a second after you start, a 20 s sleep wastes nearly all of it. If it takes several minutes, the loop makes a string of calls that tell you nothing new.
One call per condition
I replaced the loops with a single tool that blocks until one named condition is true and then exits. These are the conditions it knows:
| Condition | Done when |
|---|---|
| Merge request mergeable | GitLab reports that it can merge |
| Merge of an exact SHA | the reviewed commit itself has been merged |
| GitOps applied | the GitOps controller has applied a commit containing yours |
| Deployment ready | every replica is updated, ready and available |
| CI receipt | a specific CI run has finished, with its result |
| HTTP | a URL returns the expected status, optionally containing given text |
| Job complete | a Kubernetes Job, or the next run of a CronJob, has completed |
| Logs | enough matching log lines have arrived |
| Pods stable | pods stayed Ready with no restart or OOM kill for a window |
| Metrics | a PromQL expression stayed true for a window |
| File | a file contains a matching line |
Each wait probes, backs off between probes and stops at a bounded timeout. It checks the deadline before every probe and never starts one once the deadline has passed.
The point of making them typed is that each one knows what "done" and "failed" mean for its own condition. A deployment wait knows that old pods still terminating means the rollout isn't finished. A merge-request wait knows which GitLab statuses are transient and should be waited out, and which ones mean a human or an agent has to do something.
The output is one line of JSON
An agent doesn't need a log of every probe. It needs to know what happened. State changes go to stderr for anyone watching, and stdout gets exactly one JSON line, even for invalid arguments and unexpected errors:
{"condition": "merge", "result": "ok", "elapsed": 0.12, "merge_commit": "<sha>"}
That line is illustrative, but the shape is the real one: the condition, the result, how long it took, and the few observed fields that matter for that condition. The exit code carries the same verdict for scripts:
| Exit code | Meaning |
|---|---|
| 0 | the condition is true |
| 1 | terminal failure, such as a conflict, a moved head, a failed Job or an OOM kill |
| 2 | invalid arguments |
| 3 | the event can't happen before the deadline |
| 124 | timeout |
Code 3 turned out to be the most useful one. Suppose an agent waits on a CronJob's next run with a deadline that ends before that run is scheduled. The tool works that out on its first probe and says so right away, instead of sitting out the whole deadline and then timing out. The agent can go do other work rather than re-arming the same wait over and over.
Waits only read, and merges pin the head
The waits are read-only. They look at GitLab, the cluster, Prometheus or a file and change nothing, which makes them safe to run in the background while the agent keeps working, and safe to kill.
Merging is the one place where a call writes, and I kept it deliberately narrow. The merge call takes the SHA that was reviewed and merges only that head. GitLab's merge API accepts a sha parameter that must match the source branch's HEAD, so if anyone pushed after the review, GitLab itself refuses the merge. The call then returns head_moved, and the agent treats the new head as a new change that needs its own review. It never forces a merge or rebases the branch.
What it saved
Against the same fake GitLab server, so the comparison holds the server's behavior fixed:
| Case | Retry script | Typed wait |
|---|---|---|
| Merge request already mergeable | 20.1 s | 0.12 s |
| GitLab still checking mergeability | 40.1 s | 5.2 s |
The bigger saving is in turns. An agent that used to spend many turns watching a pipeline now makes one call, and the answer arrives when it's true. I haven't re-measured the daily poll count since the switch, so I can't give you an after number for the 765. The per-call timings above are the hard evidence I have.
Give agents a verb for waiting
If the only tools an agent has are "check status" and "sleep", it will write a polling loop every time, and every loop will be slightly different. Rules in a prompt only go so far against that. A typed wait with a deadline, one line of output and exit codes that separate "failed" from "not yet" and "can't happen in time" is a small script, and it gives the agent something better to call than sleep.
I'd also skip writing merge-retry scripts at all. Merging is the step where acting on a stale view of the world does real damage, so it's the step that should pin exactly what was reviewed and refuse anything else.