The failure-modes overview lists livelock as one of four ways a crew breaks and gives it a paragraph. It deserves more, because it’s the failure that’s hardest to notice from inside. Split-brain leaves contradictory records. Goal drift leaves an output that answers the wrong question. Livelock leaves nothing wrong: every message is well-formed, every agent is doing its job, and the task never finishes. The only symptom is a bill and a timeout.

This post is about what livelock actually looks like in a running crew, why the loop caps most people already have don’t catch it, and how to detect and bound it using the shared state instead of the conversation.

What livelock is, precisely

In operating systems, deadlock is when everyone is blocked waiting on someone else. Livelock is the opposite: everyone is busy, state keeps changing, and nothing completes. The classic picture is two people in a corridor who each step aside in the same direction, repeatedly.

A crew does the same thing with revisions. Take the simplest two-agent setup: an executor produces a draft, a critic reviews it, and the executor revises until the critic approves. This is a sensible pattern. It’s also a loop with no guaranteed exit, because “the critic approves” is a property of the critic’s judgment, not of the work.

Here is the trace that should worry you:

round 1  executor: draft v1 (summary is 340 words)
round 1  critic:   too long, target is under 250
round 2  executor: draft v2 (summary is 240 words, dropped the pricing caveat)
round 2  critic:   missing the pricing caveat, which is required
round 3  executor: draft v3 (caveat restored, summary is 335 words)
round 3  critic:   too long, target is under 250
round 4  executor: draft v4 (== v2)
...

Both agents are correct about every message. The length constraint and the completeness constraint are both real, and the draft cannot satisfy both at once given what the executor knows how to do. Neither agent has the standing to say so, and neither can see the loop, because each round looks like a normal revision. It reads like diligence right up until you notice that v4 is byte-identical to v2.

Why your loop cap doesn’t catch it

Most agent frameworks have a max_iterations or max_turns setting, and it’s good practice to set one. It’s also not the same thing as livelock protection, for three reasons.

It’s per-agent, and the loop is between agents. If the cap is “each agent may take 10 steps,” the executor takes one step per round and the critic takes one step per round. Neither ever approaches its limit. The loop runs for as long as the orchestrator keeps scheduling rounds, which is usually unbounded by design, since “keep going until approved” is the whole point of the pattern.

It conflates two different kinds of long. A run that takes 40 steps because the task is large is healthy. A run that takes 40 steps because it’s doing the same 4 steps ten times is livelock. A cap sized to allow the first will allow the second, and a cap tight enough to stop the second will kill legitimate work. Step count simply doesn’t distinguish them. Repetition does.

It terminates without saying why. When a cap fires, the usual outcome is an exception or a truncated output, indistinguishable from a crash. Livelock is a semantic outcome (“these two constraints conflict”) and deserves a semantic report, not a stack trace. If the best you can produce is “hit max_turns,” nobody learns that the spec was contradictory, and the same loop will occur on the next input with the same shape.

The common thread is that all three measure activity. What you want to measure is progress.

Three shapes

In practice the loop takes one of three forms. They need slightly different detection.

1. Oscillation

The trace above. State alternates between two or more configurations (v2, v3, v2, v3), because fixing constraint A breaks constraint B and vice versa. The tell is that states recur exactly. This is the easiest shape to detect and the one most people picture.

It comes from a real conflict in the constraints, or from an executor that can’t satisfy both at once. The second case is more common than the first: the constraints are jointly satisfiable, but the executor doesn’t find the satisfying point because each revision is a local edit to the previous draft rather than a fresh attempt.

2. The escalation spiral

State doesn’t recur, but it also doesn’t converge. Each round, the critic raises a new issue, and each fix introduces something fresh to criticize. Draft v5 isn’t v3; it’s a new thing. A hash-equality check never fires.

What gives it away is the critic’s issue stream. Issues don’t repeat, but they don’t shrink either: a steady two or three per round, forever. A healthy revision loop has a visibly decaying issue count (7, 4, 2, 0). A spiral sits flat or wobbles. This is the shape you see when the critic is a model with no fixed rubric, because a model asked to find problems will find problems. It is rarely asked what the problems are worth.

3. The polite deferral loop

This one isn’t about revisions at all. It happens in crews with routing: an orchestrator hands a task to a specialist, the specialist decides it belongs to a different specialist, and that one hands it back.

router      -> billing_agent    "customer disputes a charge"
billing     -> support_agent    "this is a service complaint, not a billing matter"
support     -> billing_agent    "involves a refund, billing handles refunds"

Every handoff is reasonable under that agent’s own description of its job. The task bounces until something times out. Nothing is revised and nothing is contradicted. It’s a routing cycle, and it’s especially easy to miss in a trace, because each hop does real-looking work: a summarization, a lookup, a rewritten task description. By hop five the task text has been paraphrased so many times it no longer matches the original. That’s goal drift riding along on top of the livelock, which is a common pairing: loops give drift more hops to accumulate across.

Detect it from state, not from the transcript

The instinct is to detect livelock by reading the conversation, perhaps by asking a model whether the agents seem stuck. Don’t. It’s expensive, it’s itself a model call that can be wrong, and it’s looking at the layer where the loop is best disguised.

The reliable layer is the shared state the crew is operating on: the draft, the ticket, the plan, the record being written. That layer is deterministic, and you can fingerprint it cheaply. This is the same move that makes split-brain and context drift queryable: get the right fields onto the write path and the failure becomes a query.

Record a fingerprint of the working artifact after every agent action, along with who acted and which constraints were open:

import hashlib
from collections import Counter, deque

def fingerprint(artifact: str) -> str:
    # normalize so whitespace-only edits don't register as progress
    canon = " ".join(artifact.split()).lower()
    return hashlib.sha256(canon.encode()).hexdigest()[:16]

class ProgressTracker:
    def __init__(self, window=12):
        self.seen = Counter()
        self.open_issues = deque(maxlen=window)   # issue count per round
        self.rounds = 0

    def record_round(self, artifact: str, issues: list[str]):
        self.rounds += 1
        self.seen[fingerprint(artifact)] += 1
        self.open_issues.append(len(issues))

    def verdict(self) -> str:
        if max(self.seen.values(), default=0) >= 2:
            return "oscillating"          # exact state recurrence
        h = list(self.open_issues)
        if len(h) >= 6 and min(h[-3:]) >= min(h[:3]):
            return "not_converging"       # issue count isn't decaying
        return "ok"

The two checks line up with the first two shapes. The recurrence check, a state seen twice, is nearly free and has almost no false positives: a draft that’s byte-identical to an earlier draft, after normalization, is not progress by any definition. The convergence check compares the best of the last three rounds against the best of the first three. Using a window minimum rather than the latest value keeps one noisy round from tripping it.

For the routing loop, the artifact is the route, not the text. Track the sequence of agent IDs a task has visited:

def routing_cycle(visited: list[str], max_revisits=1) -> bool:
    return any(visited.count(a) > 1 + max_revisits for a in set(visited))

If billing_agent has been handed the same task id twice, something’s wrong with the descriptions, and it will be wrong a third time. Keying on a stable task_id that survives paraphrasing matters here. The task text will have drifted by hop three, so a text comparison misses the cycle exactly when you need it.

Two cautions on false positives. First, normalize before hashing, or trivial reformatting resets the count and the check never fires. Second, don’t flag recurrence at the first repeat for artifacts that are legitimately refined and then partially reverted by a human-in-the-loop step; if your crew includes one, scope the tracker to the agent-only segment.

Bound it at the crew level

Detection tells you the loop exists. A budget makes it finite even when detection misses the shape you didn’t anticipate. Both belong in the design, but the budget is the backstop, so build it first.

The rule is the one from retry budgets, applied one level up: count rounds across the whole crew, per task, not per agent. The unit that matters is cross-agent exchanges, the thing that actually repeats in a livelock.

class CrewBudget:
    def __init__(self, max_rounds=6, max_cost_usd=2.00):
        self.max_rounds = max_rounds
        self.max_cost = max_cost_usd
        self.rounds = 0
        self.cost = 0.0

    def charge(self, cost_usd: float):
        self.rounds += 1
        self.cost += cost_usd

    def exhausted(self) -> str | None:
        if self.rounds >= self.max_rounds:
            return "rounds"
        if self.cost >= self.max_cost:
            return "cost"
        return None

The dollar cap matters as much as the round cap. A crew of expensive models can burn through a lot in six rounds, and a round count alone treats a cheap lookup and a long-context rewrite as equal. What a run actually costs is rarely the token price times the call count, and a loop that retries the expensive step is the dangerous one. Size the cap to what you would be comfortable paying for the task to fail.

What matters even more than the cap is what happens when it fires. A budget that raises an exception has only moved the problem. Make exhaustion a first-class outcome with a defined behavior:

  1. Ship the best candidate, labeled. Keep the artifact with the lowest open-issue count seen so far, not the latest one. In an oscillation the latest draft is no better than the earlier ones, and in a spiral it’s often worse. Deliver it with the unresolved issues attached, so the consumer knows what was not satisfied.
  2. Name the stalemate. Emit a structured reason: livelock:oscillation with the two conflicting constraints, or livelock:routing_cycle with the visited agents. This is the part nobody builds and everybody needs, because it’s what lets someone fix the spec instead of rerunning the task.
  3. Escalate with context, not a retry. Retrying a livelocked task with identical inputs reproduces the loop. If something can break the tie (a human, or a higher-authority tie-breaker agent), hand it the artifact, the issue history, and the conflict, not the original request alone.

Prevent it at the spec

Detection and budgets bound the damage. A few design choices reduce how often it happens in the first place.

Give the critic a fixed rubric. A critic told to “review this” will always find something. A critic given an explicit checklist that terminates, with pass/fail on each of N named criteria, converges, because the work can actually satisfy a checklist and cannot satisfy an open-ended mandate to be unimpressed. This is the main defense against the escalation spiral. It also makes the issue count meaningful: a list of three failing checks shrinks to zero, while “three concerns” shrinks to three different concerns.

Make constraints jointly checkable before the loop starts. The length-versus-pricing-caveat conflict above could be caught by one preflight: can any output satisfy all stated constraints? Often a cheap deterministic check will tell you (the caveat alone is 80 words, the summary must be under 250, so there’s room), and when it won’t, the spec is the thing to fix, not the crew.

Give the executor a reason to restart, not just edit. Oscillation is frequently a symptom of local editing: each revision is a patch on the last draft, so the process is a walk in a space with two attractors. After the first recurrence, or after N non-improving rounds, have the executor regenerate from the constraint list without the previous draft in context. A fresh attempt that sees both constraints at once will often find the point that patching never does. The tool-boundary approach generalizes here: put the constraint ledger where the executor reads it every round rather than relying on the conversation to carry it.

Write routing descriptions so they’re mutually exclusive and exhaustive. The deferral loop is almost always a description problem. If two specialists each have a plausible claim on “refund disputes,” the router and the specialists will disagree forever. Give every task class exactly one owner, and give the router a default owner for the ambiguous middle, so that “this could be either” resolves to a name instead of a handoff. A catch-all that accepts the task and finishes it beats a perfectly principled specialist that returns it.

Testing that your defenses work

Like the other coordination failures, livelock doesn’t show up on a friendly test run, so you have to construct it. The testing post makes the case for a scripted scheduler in place of a live model, and the same approach applies here: write a stub critic that always raises a fresh issue, and a stub executor that alternates between two fixed drafts. Then assert on the tracker, not on any output:

def test_oscillation_is_detected_and_bounded():
    tracker, budget = ProgressTracker(), CrewBudget(max_rounds=6)
    drafts = ["draft A", "draft B"]
    for i in range(20):
        if budget.exhausted():
            break
        tracker.record_round(drafts[i % 2], issues=["x"])
        budget.charge(0.01)
    assert tracker.verdict() == "oscillating"
    assert budget.rounds <= 6          # the loop ended on its own

Two assertions, two defenses: the detector named the failure, and the budget would have ended it even if the detector hadn’t. Add a test where the stub critic produces a different issue every round to cover the spiral, and one with a three-agent routing cycle for the deferral loop. If any of the three tests can’t terminate within the budget, the budget is wrong, not the test.

The summary

Livelock is what a crew does when it’s built to keep going until someone is satisfied and nobody is responsible for deciding that satisfaction is impossible. It takes three shapes: exact oscillation between states, an escalation spiral where the issue count never decays, and a routing cycle where a task bounces between specialists. A per-agent loop cap catches none of them, because it measures activity, and livelock is full of activity.

Measure progress instead. Fingerprint the shared artifact and the route after every action; a recurrence or a flat issue count is the signal. Put a budget on the crew rather than on each agent, in rounds and dollars, and make its exhaustion a real outcome: ship the best candidate with its unresolved issues, name the stalemate, and escalate with the full history. Then reduce how often it happens at the spec, with fixed critic rubrics, constraints checked for joint satisfiability before the first round, a fresh restart after the first repeat, and routing descriptions where every task has exactly one owner.

If you run a revise-until-approved loop today and can’t answer “what’s the maximum number of rounds this task can take, and what does the user get when it hits that?”, the loop has no bottom. It will find one eventually, usually on an input you didn’t test, at an hour when nobody’s watching the bill.