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.

Left, agents polling on their own timers and loading the server. Right, one wait per agent, answered together when the condition becomes true. Illustrative.

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:

ConditionDone when
Merge request mergeableGitLab reports that it can merge
Merge of an exact SHAthe reviewed commit itself has been merged
GitOps appliedthe GitOps controller has applied a commit containing yours
Deployment readyevery replica is updated, ready and available
CI receipta specific CI run has finished, with its result
HTTPa URL returns the expected status, optionally containing given text
Job completea Kubernetes Job, or the next run of a CronJob, has completed
Logsenough matching log lines have arrived
Pods stablepods stayed Ready with no restart or OOM kill for a window
Metricsa PromQL expression stayed true for a window
Filea 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 codeMeaning
0the condition is true
1terminal failure, such as a conflict, a moved head, a failed Job or an OOM kill
2invalid arguments
3the event can't happen before the deadline
124timeout

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:

CaseRetry scriptTyped wait
Merge request already mergeable20.1 s0.12 s
GitLab still checking mergeability40.1 s5.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.

Sources

Get in touch

If something here sounds like your problem, send me a few lines about what you're running.

zac@zacp.xyz