Bounded Autonomy: Retry, Escalate, Never Go Silent

Bounded Autonomy: Retry, Escalate, Never Go Silent

I run a loop that proposes work across all my repositories, builds it, and drives the resulting pull requests toward green. After a few months of operating it, I’ve concluded that the hard part of autonomous systems is not getting them to do the right thing. It’s getting them to tell you when they’ve stopped.

The characteristic failure of automation is not a bad action. It’s silence.

Two outcomes is one too few

Most pipeline stages get written with two branches: it worked, or it failed. Failure gets a label, a log line, and a comment saying a human should look at it. That feels complete when you write it.

The problem is what happens next, which is nothing. The item gets labeled blocked, the queue query filters out anything labeled blocked, and the item is now in a state no process ever reads again. It stopped being work and became sediment. Nobody is alerted, because nothing errored. The dashboard is green, the log line scrolled past a month ago, and the backlog grows, and nobody notices.

I found this in my own system the honest way, by counting: fifty-five items sitting in exactly that state, each one having been “handled” by a stage that did its job and moved on.

So the shape I now insist on is three outcomes:

  1. Succeeded. Move on.
  2. Retry, under a budget. Try again with more context, and charge one attempt.
  3. Escalate. The budget is spent, so hand it to a human with enough context to decide.

The third branch is the one that turns a dead end into a queue.

Retries need a reason attached

A retry that repeats the previous attempt verbatim is mostly a waste. The interesting design question is what the retry knows that the first attempt didn’t.

In my loop, when work fails, the reason gets captured and injected into the next attempt’s context. The agent’s second try starts from “you tried this last time and the build failed on the migration step” rather than from a blank slate. That single change moved a meaningful fraction of stuck items to done, because most of them were failing on something legible.

The general principle: a retry is only rational if something has changed. Either the world changed (a transient error, a dependency published a fix, the base branch moved) or your knowledge changed (you now know how the last attempt failed). If neither is true, you’re just paying twice for the same answer.

Budgets need an escape hatch that is a person

An unbounded retry loop is worse than a dead end, because now you’re burning money to stay in the same place. So each item carries an attempt counter, and when it runs out the stage does something specific: it asks me a question.

The shape of that question matters more than I expected. A good escalation contains what was being attempted, what was tried, why the budget ran out, and a small menu of options. Mine offers three: give guidance, drop it, or I’ll handle it by hand. Giving me a menu instead of an open prompt is what makes it a five-second decision rather than a thing I put off.

Two mechanical details that keep escalation from becoming noise:

  • Ask once. A marker records that a question was raised, so the item does not re-ask every cycle. An escalation that repeats is an alert that gets muted, and a muted alert is the silence you were trying to avoid.
  • Cap open questions. There’s a per-repository ceiling on outstanding questions. If a project has accumulated five open decisions, the correct behavior is to stop generating new ones and wait, not to bury me.

Distinguish “failed” from “never attempted”

This is the detail I’d most like to hand to someone building anything similar, because it’s invisible until you look for it.

My agent runner exits with a distinct code when a provider’s usage limit is hit. No work was attempted at all. But the calling stage was written like this:

run_agent "$prompt" || true
record_attempt "$item"

The || true throws away the exit code, and the attempt is recorded unconditionally. So a run where nothing happened consumed the same retry budget as a genuine failure. Hit a quota three times in a row and an item exhausts its budget and posts a give-up message without an agent ever having looked at it.

The fix is to treat the exit code as what it is, a typed result:

run_agent "$prompt"; rc=$?
if [ "$rc" -eq 75 ]; then
    release_lock "$item"      # provider paused; nothing was attempted
    return 1                  # stop the cycle, do not charge an attempt
fi
record_attempt "$item"        # any other outcome counts as one real try

Anywhere a subprocess can fail in more than one distinguishable way, its exit code is a protocol, and || true deletes the protocol. Write the codes down in a table and make every caller branch on them.

Never fabricate a result

A related rule, and in agent systems it has teeth. When a step that produces text fails, the temptation is to fall back to a placeholder so downstream code has something to work with. Do not do this when the output is durable.

In my system, agents and I converse through a transcript, and that transcript is an input to future runs. A fabricated “I wasn’t able to determine that” written in the agent’s voice becomes indistinguishable from a real reply the next time anything reads it. The failure is no longer visible as a failure, and every later decision inherits it.

The corollary is that a successful exit with empty output is also a failure. A run that returns zero and produces no content did not answer the question, and treating that as success is the same bug wearing a nicer hat.

Instrument the claim, not the error

Silent no-ops are invisible to error monitoring, because nothing errors. So the detector has to be built out of positive claims rather than exceptions.

The pattern that works: any stage that announces work must also report how much it finished, and a zero deserves an alarm. I had a log line reading ideation sweep starting over 12 repos printing every day, healthily, with no per-repo work after it. Nothing was wrong from the monitoring system’s point of view. The whole class of problem is caught by pairing every “starting N” with a “completed M” and alerting when M is zero while N is not.

The checklist

If you’re building anything that runs unattended:

  1. For every terminal failure state in your pipeline, name the process that revisits it. If there is none, you have a dead end.
  2. Give retries new information, or don’t retry.
  3. Bound every retry budget, and make the end of the budget a question to a human with options.
  4. Ask once, and cap outstanding questions.
  5. Distinguish “attempted and failed” from “never attempted” in your exit codes, and never charge a budget for the second.
  6. Never write a fabricated result into a durable record. Empty output on success is a failure.
  7. Pair every “starting N” log line with a “finished M,” and alert on zero.

None of this makes the system smarter. It makes the system’s confusion visible, which in practice has been worth much more.