DEV Community

Cover image for How to cap a scheduled agent's runs, and the three ways the cap leaks
Fewparts
Fewparts

Posted on Originally published at fewparts.co.uk

How to cap a scheduled agent's runs, and the three ways the cap leaks

The thing people picture when they put an agent on a schedule is a loop at three in the morning. Not a dramatic failure — a boring one. Something returns an empty result, the agent decides to try again, and the retry is indistinguishable from the first attempt, so it tries again. Nobody is awake. The first sign is the bill.

A run cap is the cheapest defence against that, and it's the one I'd add before anything else, because it's the only guard that bounds the worst case rather than the typical one. A write guard limits what a run can damage. A cap limits how many runs there are.

It's also easy to write badly, in ways that look fine until the day they matter. I wrote one, tested it, mutation-tested the tests, and then found a case where it let seven runs through a cap of five. All three leaks below are things I reproduced rather than reasoned about, which turns out to matter — one of them holds four times out of five, which is exactly how a bug like this survives.

Count runs, not money

The instinct is to cap spend, because spend is what you actually care about. Resist it, at least at this layer. Nothing handed to a scheduled agent gives it a running total in pounds — a hook payload describes a pending tool call, not its price, and a task wakes up knowing nothing about what previous tasks cost. You can reconstruct spend afterwards from a provider's usage API, but "afterwards" is the wrong tense for a guard.

Runs are a worse proxy and a much better lever: they're knowable at the only moment a guard can act, which is before the work starts. Forty runs a month with a rough sense of what a run costs is a real bound. A dollar figure you can't read until tomorrow is not.

So: a counter, a window, and a refusal.

Write the claim before the work, not after

Here's the whole thing. Append-only file, one JSON object per line.

import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";

/** Every run ever claimed. One JSON object per line; bad lines are skipped. */
export function readRuns(file) {
  if (!existsSync(file)) return [];
  return readFileSync(file, "utf8")
    .split("\n")
    .filter(Boolean)
    .map((line) => {
      try {
        return JSON.parse(line);
      } catch {
        return null;
      }
    })
    .filter((r) => r && typeof r.month === "string");
}

export function usedIn(runs, month) {
  return runs.filter((r) => r.month === month).length;
}

export function claimRun({ file, limit, timeZone = "UTC", now = new Date() }) {
  const month = monthKey(now, timeZone);
  const used = usedIn(readRuns(file), month);
  if (used >= limit) return { allowed: false, used, limit, month };
  mkdirSync(dirname(file), { recursive: true });
  appendFileSync(file, JSON.stringify({ at: now.toISOString(), month }) + "\n");
  return { allowed: true, used: used + 1, limit, month };
}
Enter fullscreen mode Exit fullscreen mode

The name is doing work. It's claimRun, not recordRun, and it's called before the agent does anything — not at the end when you know how the run went.

That ordering is the first leak, and it's the one I'd expect most people to get backwards, because recording an outcome feels like the honest thing to do. If you increment the counter on success, every run that crashes is free. Now read that back with the failure mode in mind: the runaway you're capping is usually a crash loop, so the exact case that burns your month is the case your counter ignores. A cap that only counts good runs is a cap that only binds when nothing is wrong.

readRuns skipping bad lines matters for the same reason. A line can go bad two ways — an append cut off mid-write, and one that's valid JSON but isn't a run record. The second is the one a lazy filter lets past. .filter(Boolean) catches the truncated line and happily counts {} as a run, which is wrong in the safe direction; but the mirror of that mistake, dropping anything unparseable and anything unfamiliar, is how a month's history quietly becomes zero.

The month boundary is a timezone question

used >= limit is easy. month is where the bugs live.

export function monthKey(date, timeZone = "UTC") {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric",
    month: "2-digit",
  }).formatToParts(date);
  const year = parts.find((p) => p.type === "year").value;
  const month = parts.find((p) => p.type === "month").value;
  return `${year}-${month}`;
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice.

The zone is a parameter, because the answer genuinely differs. A run at 2026-08-31T23:30:00Z is in August by UTC and September in London. If your cron fires at 00:30 local on the first of the month, and your counter thinks in UTC, the first run of every month is charged to the month that just closed — the one whose allowance is already spent. Your month starts a day late, every month, and the symptom is a refusal on the first attempt followed by everything working, which reads like a flake.

Doing this with getMonth() and a hand-rolled offset is the version that breaks in October when the clocks change. Intl already knows; let it answer.

The key is stamped into the record, not re-derived on read. Each line stores month alongside at. If you instead recompute the month from at on every read, then changing TZ on the machine retroactively moves past runs between months — a config change silently rewrites history and hands back an allowance you already spent. Storing the key freezes what each run was counted against; keeping at alongside it means you can still audit the decision later.

A cap the agent can edit is not a cap

This one isn't code, and it's the leak most likely to be sitting in your setup right now.

If the agent's own write scope includes the file that holds the limit, the limit is a suggestion. It doesn't take malice — an agent tidying its configuration, or "fixing" the thing that just refused it, is enough. The same goes for the ledger: an agent that can rewrite the counter can reset the counter.

So split policy from data, and put the split somewhere the agent can't reach:

  • The limit lives in the wrapper that decides whether to invoke the agent at all, or in an environment variable set by whatever schedules it. Not in a file the agent edits.
  • The counter has to be writable by the run, so treat it as append-only and keep it in version control. You can't stop a rewrite from a process that has the file, but a rewrite that has to survive git diff is a rewrite you'll see.

The honest version of this: the cap is enforced by the thing outside the agent, and everything inside is bookkeeping. If your agent is the process that reads its own limit and decides whether to continue, you don't have a cap — you have a convention, and it holds exactly as long as nothing goes wrong. This is the same shape as the write guard that can't see a shell redirect: the boundary is only real where something other than the agent is drawing it.

The leak I only found by racing it

Look at claimRun again. It reads the file, decides, then appends. Two processes can both read used = 4 against a limit of 5, and both append.

Whether that matters depends on whether two runs ever overlap — and on a schedule they do, the moment a run takes longer than its interval. So I spawned twelve processes against a cap of five and counted the lines.

The first four attempts held perfectly. Five allowed, five lines, no leak. I nearly wrote that down as "the race is theoretical in practice."

It wasn't. The processes never overlapped: Node takes tens of milliseconds to boot, so twelve spawns queue up politely and each one finishes before the next reads the file. Once I held every worker at a shared wall-clock instant so they entered the critical section together, twenty trials looked like this:

lock=false  4/20 trials exceeded a cap of 5   worst: 7
lock=true   0/20 trials exceeded a cap of 5   worst: 5
Enter fullscreen mode Exit fullscreen mode

Four in twenty, and the bad case was 40% over budget. That failure rate is the dangerous part — it's frequent enough to happen to you and rare enough that you'll conclude it's fine.

The fix is a lock around the read-and-append, and the primitive is openSync(path, "wx"): it creates the file and fails if it already exists, in one syscall. That indivisibility is the entire point, since check-then-create is the bug we're fixing.

import { openSync, closeSync, rmSync, statSync } from "node:fs";

export function withLock(lockFile, fn, { timeoutMs = 5000, staleMs = 60_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  let fd = null;
  for (;;) {
    try {
      fd = openSync(lockFile, "wx");
      break;
    } catch (err) {
      if (err.code !== "EEXIST") throw err;
      // A holder that died without cleaning up would block every future run,
      // which turns a run cap into a run ban. Age it out.
      try {
        if (Date.now() - statSync(lockFile).mtimeMs > staleMs) rmSync(lockFile, { force: true });
      } catch {}
      if (Date.now() > deadline) throw new Error(`could not acquire ${lockFile} in ${timeoutMs}ms`);
    }
  }
  try {
    return fn();
  } finally {
    closeSync(fd);
    rmSync(lockFile, { force: true });
  }
}
Enter fullscreen mode Exit fullscreen mode

Then wrap the critical section — read, decide, append — and nothing else:

export function claimRun({ file, limit, timeZone = "UTC", now = new Date() }) {
  const month = monthKey(now, timeZone);
  mkdirSync(dirname(file), { recursive: true });
  return withLock(file + ".lock", () => {
    const used = usedIn(readRuns(file), month);
    if (used >= limit) return { allowed: false, used, limit, month };
    appendFileSync(file, JSON.stringify({ at: now.toISOString(), month }) + "\n");
    return { allowed: true, used: used + 1, limit, month };
  });
}
Enter fullscreen mode Exit fullscreen mode

The stale-lock timeout is not optional decoration. A crashed holder leaves the file behind, and a lock file nobody will ever release converts your run cap into a permanent refusal — a guard that fails from "too many runs" to "no runs" is still an outage, just a quieter one.

Testing the test

The harness came out at 22 assertions and passed on the first run, which after several rounds of tests-find-bugs made me suspicious rather than pleased. So I broke the cap on purpose, five ways, and checked the harness noticed each: an off-by-one at the limit, a month derived without the zone, counting every line rather than every run, deduplicating claims by timestamp, and removing the append entirely.

All five were caught, but the last one is worth reporting properly. With the append removed, the harness didn't fail an assertion — it died with ENOENT from its own second scenario, which read the file without checking it existed. Non-zero exit, so my mutation runner scored it as caught. What a human would have got is a stack trace pointing at the test's plumbing rather than a sentence naming the broken behaviour.

That's the same failure the guards themselves have, one level up. A diagnostic that crashes instead of reporting still tells you something is wrong; it just makes you debug the instrument first, at the exact moment you're trying to debug something else.

What this still doesn't do

It caps runs on one machine, with one file. Two schedulers, two checkouts, or a container that starts from a fresh volume each time are three different ways to get a counter that resets without anyone touching it — and the failure is silent, because an empty ledger and a fresh month look identical.

It also only ever counts upward. A ledger with two entries in a month is under any cap you'd set, and so is a ledger with zero, so the guard is perfectly happy on the day the scheduler stops firing altogether. The same file answers that question, but only if you check it against the schedule rather than reading it — a log genuinely cannot contain a record of the run it didn't get.

And it caps count, not cost. A run that burns ten times the tokens of a normal one still spends exactly one. If your runs vary that much, the cap bounds your worst month at ten times what you expected, which is a real bound but not the one you had in mind. Pair it with the provider's own spend limit if there is one; the two guards fail in different directions, which is the only reason to have both.

The code above is the whole cap; there's no held-back version of it. If you only take one thing away, take the ordering — claim before you work, or the runs that cost you most are the ones you never count.


Originally published at fewparts.co.uk.

Agent Guardrails Kit is the free, assembled version of this code — same modules, wired together, with the tests.

Top comments (0)