Ground Truth

Two Correct Termination Proofs Compose Into a Hang

Mostafa DhouibMostafa Dhouib··7 min read
The short answer

Your planner stops. Your executor stops. Both proofs are valid and neither is wrong. Put them in a loop together and the system runs forever, because termination is not a property that composes. Podelski and Rybalchenko formalised the fix in 2004, and it explains exactly why per-component monitoring cannot see a hang that lives between components.

Two Correct Termination Proofs Compose Into a Hang

The short answer. Termination does not compose. A planner that provably stops and an executor that provably stops can, wired into a loop, run forever, and both original proofs remain valid throughout. This is not an implementation bug, it is a property of the mathematics, which is why every component's dashboard can be green while the system hangs. Podelski and Rybalchenko's 2004 transition invariant work gives the correct shape of a proof for the composed system: you need a measure over the combined state, and no amount of per-component measure adds up to one.

Here is a system that fails in a way nobody's monitoring can see.

A planner takes a goal and emits a list of steps. It has a bound: at most twenty steps per plan, and it always returns. Proven, tested, correct.

An executor takes a step and runs it. It has a bound: at most ten tool calls per step, and it always returns. Proven, tested, correct.

The executor reports back to the planner. If a step failed, the planner replans.

Every component provably terminates. The system does not.

  1. Planner measure descends 5 to 3
    healthy
  2. Executor measure descends 4 to 2
    healthy
  3. Planner replans
    normal
  4. The replan resets the executor measure back to 4
    each component healthy, the composition never terminates
  5. And the cycle repeats
Both dashboard panels are green and correct, the alert never fires, and it ran 47,000 dollars over 11 days.
FigureTwo components that each provably make progress can compose into a system that never stops, and no per-component check can see it.

Why this is not a bug you can find by reading the code

The instinct on hearing this is that somebody made a mistake, that one of the proofs is subtly wrong, that there is an off-by-one somewhere. There is not. Both proofs are correct statements about their components, and they remain correct while the system hangs.

The reason is a property of termination arguments generally, and it is worth stating precisely because the precision is what makes it actionable.

A termination proof is a ranking function: a value computed from the program's state, which strictly decreases every pass and cannot decrease forever because it is bounded below. Floyd formalised this in 1967. Steps-remaining is a ranking function for the planner. Calls-remaining is a ranking function for the executor.

Now compose them. On a replan, the planner's counter resets to twenty. On a new step, the executor's counter resets to ten. Neither ranking function decreases across the composed loop, because each is reset by the other's activity.

Planner: at most 20 steps, always returns
steps-remaining is a valid ranking function
Executor: at most 10 calls, always returns
calls-remaining is a valid ranking function
Executor reports failure, planner replans
steps-remaining resets to 20
Planner emits a new step, executor restarts
calls-remaining resets to 10
The failure point
Neither measure decreases across the composed loop, so the composed loop has no ranking function and need not terminate.
Both original proofs remain correct the entire time the system is hung. Termination is not a property that composes.
FigureTwo valid termination proofs, and the composition has none. Nothing here is a bug, which is why reading the code does not find it.

So the composed system has no ranking function, which means it has no termination proof, which means it need not terminate. The two proofs did not add up to a third proof. They were never the kind of thing that adds up.

The correct shape of the argument

Podelski and Rybalchenko's 2004 result on transition invariants is the standard reference here, and the useful part for engineering is the shape rather than the formalism.

The idea is that instead of demanding a single global ranking function, you argue about the transition relation of the whole system: every possible way it can move from one state to a later one must be covered by one of a finite set of well-founded relations. It makes termination proofs for looping, mutually recursive systems tractable, and there are tools that automate it.

For our purposes the engineering translation is short:

The measure has to be over the combined state. Not the planner's state, not the executor's state, but something computed from both, which no component resets and which strictly decreases as the system moves forward.

A measure that any component can reset is not a measure. This is the whole failure in one line, and it is a check you can apply to a design in about a minute.

What you have
One measure per component
Each resets when its component is re-entered
Each is a valid proof about its own component
None of them is a proof about the system
What you need
One measure over the combined state
Computed in code from ground truth
No participant can reset it
It strictly decreases as the system moves forward
The one-minute design review question: what decreases across the whole system, and can any component reset it?
FigureThe engineering translation of Podelski and Rybalchenko's 2004 transition invariants: the argument has to be about the whole system, not about its parts.

What such a measure looks like in practice

The good news is that for real tasks this measure is usually available and usually boring, which is why it gets skipped rather than being genuinely hard.

Rows unmigrated. Not attempts made. Rows that still need to move. Nothing in the planner or executor can reset it, and it hits zero when the job is done.

Tests still failing. Not fix attempts. The count of failing tests. A replan does not change it. Running a step might.

Records unmatched, tickets unresolved, files unprocessed, fields still empty. The pattern is the same: a count of remaining work, computed in code from ground truth, that no control-flow event can restore.

The tell that you have the wrong measure is that it goes up when the system does something. Attempts, iterations, tokens, calls, elapsed time: these all measure spend. Spend is bounded and worth bounding, but a fuel gauge cannot distinguish arriving from running dry, and that distinction is exactly the one a composed system loses.

Why your monitoring is structurally blind to this

This is the operational consequence and it is the reason the failure survives in production for weeks.

Per-component monitoring answers per-component questions. Did the planner return? Yes, in four seconds. Did the executor return? Yes, in eleven seconds. Error rate? Zero. Timeouts? None. Every dashboard is green and every dashboard is honest.

Planner latency4s, healthy
Executor latency11s, healthy
Error rate0 percent
Timeoutsnone
Time since work remaining decreasednobody measures thisthe only number that would show the failure
You cannot fix this with more per-component metrics, however many you add. It is the same reason you cannot find a seam by staring harder at either side of it.
FigureEvery one of these readings is honest, and together they describe a system that has done nothing for two hours. The hang lives between components, and no component's metrics contain it.

The hang lives in the relationship between the components, and there is no component whose metrics contain it. You cannot fix this by adding more per-component metrics, however many you add, for the same reason you cannot find the seam by looking harder at either side of it.

What you need is one metric at the level of the task:

Time since the remaining-work measure last decreased. Not time since the last activity, which is always near zero in a hung system because the components are busy. Time since actual progress.

That single number distinguishes a system doing hard work slowly from a system doing nothing energetically, and almost nobody has it. It is the sensor placed at the level the failure occurs at, which is the general form of the fix.

The same shape in three other systems

Once you know composition is where this hides, you find it outside AI immediately.

Retry loops around retry loops. An HTTP client retries three times. The service calling it retries three times. The job scheduler retries three times. Each bound is real; the composition is twenty-seven attempts, and if a fourth layer exists nobody can say what the total is without drawing it out.

Mutually recursive workflow steps. Step A escalates to step B on failure; step B routes back to A when it cannot resolve. Both have bounds per invocation. The pair can ping-pong indefinitely and every invocation is inside its limit.

Cache invalidation cycles. Service A invalidates B's cache, B recomputes and invalidates A's. Each operation terminates. The pair can chase each other for as long as traffic keeps arriving.

In every case the fix has the same form: find the quantity that represents actual progress toward the outcome, verify no participant can reset it, and bound the system on that.

The review question

Ask of any multi-component loop: what decreases across the whole system, and can any component reset it?

If nobody can answer the first half, the system has no termination proof regardless of how many its components have. If the answer to the second half is yes, you have found the hang before it shipped.

FAQ

Why does my agent hang when every component terminates? Because termination does not compose. Each component's ranking function is reset by the other component's activity, so the composed loop has no decreasing measure and therefore no termination proof. Both original proofs stay valid the entire time the system is hung.

What is a ranking function? A value computed from program state that strictly decreases on every pass and is bounded below, so it cannot decrease forever. Floyd formalised it in 1967 and it is the standard way to prove a loop terminates. Steps-remaining and calls-remaining are both ranking functions for their own components.

What are transition invariants? Podelski and Rybalchenko's 2004 approach to proving termination for systems where a single global ranking function is hard to find. Instead of one measure, you cover every way the system can move forward with a finite set of well-founded relations. The engineering takeaway is that the argument must be over the combined state.

Why doesn't per-component monitoring catch this? Because the hang lives in the relationship between components, and no component's metrics contain it. Both return promptly, error rates are zero, and every dashboard is honest. The metric you need is at task level: time since the remaining-work measure last decreased.

What measure should I use instead of an iteration count? A count of remaining work computed in code from ground truth: rows unmigrated, tests still failing, records unmatched. The test is that it must shrink as the task nears completion and that no component can reset it. If it goes up when the system does something, it is measuring spend rather than progress.

Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program