A Cycle That Actually Stops
TL;DR
- A discovery cycle (finders plus a judge, where fixing an accepted finding exposes the next one) re-runs its finders every round, so it re-surfaces findings the judge already rejected. It converges only if it dedupes each round against every finding ever SEEN, not against the CONFIRMED (judge-accepted) subset.
- Same seeded corpus, two dedupe keys. Arm SEEN reached two dry rounds at round 8 and stopped itself. Arm CONFIRMED was still reporting “new” work at the 30-round cap, because every judge-rejected finding re-entered each round. Both fixed the same 6 real findings. n=1 per config, deterministic.
key(finding)is the one function that decides it. The cap is a runaway backstop, not a finish line. You need both.
📊 Result, up front. One seeded corpus: a 6-finding cascade plus a fixed pool of 4 dead ends the judge always rejects. Two arms differ by one line. Arm SEEN converged at round 8, judged 10 findings total, judged the dead ends 4 times. Arm CONFIRMED hit the 30-round cap, judged 126 findings total, judged the same dead ends 120 times, with
deduped away = 0in every single round. Both arms fixed the same 6 real findings.
| Arm | Dedupe memory | Rounds | Stopped by | Findings judged | Dead-end judgements | Real fixed |
|---|---|---|---|---|---|---|
| SEEN | every finding surfaced | 8 | convergence (2 dry rounds) | 10 | 4 | 6 |
| CONFIRMED | judge-accepted only | 30 | the cap | 126 | 120 | 6 |
Your loop stops at max_iterations and calls it done. It isn’t. Every run re-examines issues it already dismissed, and the only reason it ever halts is the cap. This post is the measurement for that: the same seeded corpus run two ways, with one line of difference, and the round counts pulled apart. The topic is agent loop convergence, and the whole result is one function. The one-line difference is this:
# Arm SEEN: remember every finding the finders surfaced, accepted or not.seen.add(key(f)) # for EVERY surfaced finding
# Arm CONFIRMED: remember only what the judge accepted.confirmed.add(key(f)) # only when judge(f) is TrueHonest framing before any number: the finder and judge in this run were deterministic code, on purpose. Convergence of a discovery cycle is a property of the bookkeeping (the loop, the dedupe set, key(), the cap), not of an agent’s intelligence. Modeling the finder and judge as code isolates the dedupe key as the sole variable and makes the run reproducible. No LLM judged anything here. The artifact under test is the orchestration, and it maps directly onto the loop-until-dry pattern we run in the Workflow harness: a seen Set, a dry counter, and a budget/agent-count backstop.
Prerequisites:
- Finished Loop Part 3 (stop conditions and verification): the loop shipped with
max_iterations: 5and anexit_when. This post distinguishes that ceiling from convergence. - Finished Graph Part 6 (the fresh-context judge): the judge that rejects a finding is the exact mechanism that creates this post’s bug.
- Helpful, not required: Graph Part 4 (the reduce step is free): the dedupe set and
key()are plain code, arrange-work, not a node.
The loop had a ceiling, not a finish line
The Loop-era spec stopped at max_iterations: 5, and that felt like done. It’s a ceiling, not a finish line. A cap protects you from a runaway when something goes wrong. It never decides that the work is finished. Those are two different jobs, and conflating them is why loops “stop” without converging.
Every graph in this series so far ran each edge forward exactly once: fan out, barrier, reduce, judge, route. Part 8 (conditional edges) closed on the next measured problem. This post adds the first edge that points backward. A cycle is a backward edge: the finders run again because fixing one finding can expose the next. The question a backward edge raises, and the one a forward-only graph never had to answer, is whether it terminates on its own. A cap can always force it to quit. Convergence is the loop deciding it’s done.
Dedupe against everything seen, not against confirmed
A discovery cycle re-runs its finders each round, so it re-surfaces findings it surfaced before, including ones the judge already rejected. It converges (runs dry) only when it dedupes each round against every finding it has ever seen, keyed by a stable key(finding). Dedupe against the confirmed set only, and every judge-rejected finding looks new again next round. That single choice is the whole story of agent loop convergence.
Here’s the cycle in three sentences. Each round the finders surface candidate findings; the judge accepts or rejects each one; code fixes the accepted ones, and a fix can expose a new finding, so the loop goes round again. Because the finders re-run every round, they re-surface candidates the judge has seen before. What you remember across rounds is the only thing that decides whether that re-surfacing ever ends.
Round R ─▶ finders surface findings │ ┌────────┴────────────────────────────┐ ▼ Arm SEEN ▼ Arm CONFIRMED dedupe vs SEEN dedupe vs CONFIRMED (dead ends filtered out) (dead ends re-admitted) │ │ ▼ ▼ judge only fresh work judge everything again │ │ ▼ ▼ fix accepted ─▶ exposes next fix accepted ─▶ exposes next │ │ ▼ ▼ fewer new each round ─▶ dry same dead ends re-enter ─▶ never dryThe left branch shrinks the judge’s work every round until nothing new arrives. The right branch hands the judge the same dead ends forever. Same finders, same judge, same corpus. The only difference is which set the dedupe reads from.
Why the loop keeps rediscovering the same dead ends
Dedupe against confirmed, and every judge-rejected finding looks new next round, so the loop re-examines the same dead ends round after round and never runs dry. The judge is working correctly. It rejects a false positive every time it’s asked. The defect is entirely in what the dedupe key is keyed on, which is why the fix is one line and not a smarter judge.
Trace one concrete dead end, D1, a plausible-looking candidate that’s actually fine. Under Arm SEEN, round 1: D1 is surfaced, it’s not in seen, so it gets judged (rejected), and key(D1) goes into seen. Round 2 onward: D1 is surfaced again, but key(D1) is already in seen, so it’s deduped away before the judge ever looks at it. Judged once, total.
Under Arm CONFIRMED, round 1 is identical: D1 is surfaced, judged, rejected. But nothing rejected enters confirmed, so key(D1) is not remembered. Round 2: D1 is surfaced again, still absent from confirmed, so it reads as new, and the judge re-examines it, re-rejects it. Every round. Forever.
Finding D1 | In seen? | In confirmed? | Re-judged next round? |
|---|---|---|---|
| Arm SEEN, after round 1 | yes | (n/a) | no, filtered before the judge |
| Arm CONFIRMED, after round 1 | (n/a) | no (rejections never enter) | yes, re-judged every round |
Multiply D1 by every false positive the finders can generate, and the loop never runs dry. It oscillates on the same dead ends and retires real work only incidentally. The one-line fix (the judge whose rejection triggers all this is Part 6’s): add every surfaced finding to the seen-set, accepted or not.
for f in finders(corpus): if key(f) in seen: # already surfaced this run: skip it continue seen.add(key(f)) # remember it BEFORE judging, rejected or not if judge(f): fix(f) # a fix can expose the next real findingSame corpus, two dedupe keys, one line apart
Both arms run one seeded corpus with identical finders, an identical judge, an identical convergence condition (two consecutive rounds with zero new findings), and an identical cap. The only difference is the dedupe memory: Arm SEEN adds every surfaced finding to its set; Arm CONFIRMED adds only judge-accepted ones. That’s the controlled variable, and it’s one line.
The corpus has two deliberately built properties. First, a cascade of k real findings R1..Rk, where Ri becomes visible only after R(i-1) is fixed. That forces the loop through multiple rounds instead of one shot. Second, a fixed dead-end pool of m findings D1..Dm, visible every round, which the judge always rejects. The main run uses k=6 and m=4 with a 30-round cap. The harness records ground truth per round: findings surfaced, deduped away, accepted, rejected, dead-end judgements, cumulative fixes, and the dry streak. It’s n=1 per config, said plainly, and deterministic, so every number reproduces on your machine.
def run_arm(arm, k, m, max_rounds, patience=2): corpus, memory, dry, rnd = Corpus(k, m), set(), 0, 0 while dry < patience and rnd < max_rounds: # convergence AND the cap rnd += 1 surfaced = corpus.finder() fresh = [f for f in surfaced if key(f) not in memory] # the dedupe for f in fresh: if arm == "seen": memory.add(key(f)) # remember everything surfaced if judge(f): if arm == "confirmed": memory.add(key(f)) # remember only what's accepted corpus.apply_fix(f) # cascade: expose the next dry = dry + 1 if len(fresh) == 0 else 0 # two dry rounds = converged return rnd, (dry >= patience)Agent loop convergence, measured: 8 rounds versus 30
Arm SEEN reached two consecutive dry rounds at round 8 and stopped itself, having retired all 6 real findings. Arm CONFIRMED never hit two dry rounds; it ran to the 30-round cap still reporting 4 “new” findings that round, because the 4 dead ends re-entered every round. Both arms fixed the same 6 real findings. Convergence is a termination property, not a correctness one.
The waste is concrete. Arm SEEN judged 10 findings total across its 8 rounds and judged the dead-end pool 4 times (once each, in round 1, then remembered forever). Arm CONFIRMED judged 126 findings and re-judged the same 4 dead ends 120 times. That’s 12.6x the judging and 30x the dead-end re-judgements for the identical 6 fixes. The fingerprint of the bug is one column: deduped away = 0 in every single CONFIRMED round. A healthy dedupe set fills up and starts filtering; this one never remembered anything worth filtering.
| Config | Arm | Rounds | Converged | Real fixed | Findings judged | Dead-end judgements |
|---|---|---|---|---|---|---|
| Main (k=6, m=4) | SEEN | 8 | yes | 6 | 10 | 4 |
| Main (k=6, m=4) | CONFIRMED | 30 | no (capped) | 6 | 126 | 120 |
| Control (m=0) | SEEN | 8 | yes | 6 | 6 | 0 |
| Control (m=0) | CONFIRMED | 8 | yes | 6 | 6 | 0 |
| Robustness (k=10, m=2) | SEEN | 12 | yes | 10 | 12 | 2 |
| Robustness (k=10, m=2) | CONFIRMED | 40 | no (capped) | 10 | 90 | 80 |
The control row is the guardrail against overclaiming. Run the same cascade with no dead ends (m=0) and the two arms are identical: 8 rounds each, both converge. With nothing rejected to re-admit, seen and confirmed hold the same keys, so the two strategies are indistinguishable. That isolates the cause precisely: the divergence in the main run is caused entirely by the dead-end pool, the findings the judge rejects. Dedupe-by-confirmed is broken specifically because it forgets rejections. The robustness row (k=10, m=2) shows the same shape at different sizes: SEEN converges at k + patience rounds, CONFIRMED runs to the cap. The mechanism generalizes; the exact numbers are just this corpus.
One honest wrinkle worth stating: this measures rounds and work-units, never dollars and never model tiers. How much the CONFIRMED arm would cost to run on real agents is a pricing question, and that’s a later post in this series. Here the unit is rounds and judgements.
Why you need both a cap and a convergence condition
The convergence condition is the finish line; the cap is the runaway backstop for when the finish line is unreachable. Neither substitutes for the other. Convergence without a cap can still run away if key() is wrong: Arm CONFIRMED is that failure mode made deliberate, and without max_rounds it never returns. A cap without convergence just truncates mid-work at an arbitrary ceiling, which is the Loop Part 3 instinct.
In the Workflow harness we run, this maps to concrete parts. The loop-until-dry pattern is a seen Set plus a dry counter (while dry < 2). budget.remaining() gates the spend as a runaway guard, nothing more. A hard agent-count backstop (our 1000-agent ceiling) exists precisely so a non-converging loop dies loudly instead of quietly draining. The dedupe set and key() are plain code, arrange-work, not a node, so there’s nothing exotic to price or schedule here.
key(finding) is the one function that decides which regime you’re in, and it has two failure modes worth naming. Too loose, and distinct real findings collide onto the same key and get skipped, so the loop under-runs and misses work. Too tight, and re-surfaced dead ends produce a slightly different key each round, slip past the filter, and the loop never dries, exactly the CONFIRMED behavior by another route. “Dedupe-by-seen converges” holds given a key() that correctly identifies re-surfaced findings. It is not magic, and the two failure modes are the scope.
So the carry-forward exercise is small and it’s the whole point: write key(finding) for your own work. A stable identity over the finding’s location and its rule (or a normalized description) is usually right. That one function is what decides whether your cycle converges.
Try it now
The run above is one self-contained harness with no network and no LLM calls, so you can reproduce every number.
# 1. Run all three configs (main, m=0 control, k=10/m=2 robustness).python3 graph-cycle-that-converges--harness.py
# 2. Watch the SEEN arm's dry column climb to 2, then stop:python3 graph-cycle-that-converges--harness.py | grep -A 10 "Arm SEEN"
# 3. Confirm the bug's fingerprint: deduped-away is 0 for every CONFIRMED round.python3 graph-cycle-that-converges--harness.py | grep "DIVERGENCE"Expected output from step 3:
>>> DIVERGENCE [MAIN]: SEEN 8 rounds (converged), CONFIRMED 30 rounds (CAPPED). Real findings fixed: SEEN=6, CONFIRMED=6. Dead-end judgements: SEEN=4, CONFIRMED=120.>>> DIVERGENCE [CONTROL m=0]: SEEN 8 rounds (converged), CONFIRMED 8 rounds (converged). Real findings fixed: SEEN=6, CONFIRMED=6. Dead-end judgements: SEEN=0, CONFIRMED=0.>>> DIVERGENCE [ROBUSTNESS k=10 m=2]: SEEN 12 rounds (converged), CONFIRMED 40 rounds (CAPPED). Real findings fixed: SEEN=10, CONFIRMED=10. Dead-end judgements: SEEN=2, CONFIRMED=80.The SEEN arm’s per-round new column decays to 0; the CONFIRMED arm’s plateaus and never reaches a dry round. That decay-to-zero is convergence, printed.
FAQ
What makes an agent loop converge?
The rule for agent loop convergence is one line: dedupe every round against every finding the finders have ever surfaced, keyed by a stable key(finding), and stop after two consecutive rounds that surface nothing new. A cap alone doesn’t converge; it truncates. In the run above, that policy dried the loop at round 8, while deduping against confirmed findings only ran to the 30-round cap.
Why does my agent loop keep finding the same issues every run?
You’re almost certainly deduping against confirmed (judge-accepted) findings only. Every candidate the judge rejects stays out of that memory, so it re-enters as “new” next round and gets re-judged. Add every surfaced finding to the seen-set, accepted or not. In the measured run this was the difference between 4 and 120 dead-end judgements for the identical work.
Do I still need max_iterations if I have a convergence condition?
Yes. The cap is a runaway backstop for a broken key(), not a substitute for the finish line. The CONFIRMED arm shows exactly what a cap-without-convergence run costs: 30 rounds, 126 findings judged, and it only halted because the cap fired. You want both, doing two different jobs.
What should key(finding) be?
A stable identity over the finding’s location and its rule, or a normalized description. Too loose and distinct real findings collide onto one key and get skipped, so the loop under-runs. Too tight and a re-surfaced dead end keys differently each round, slips past the filter, and the loop never dries. That single function decides whether the cycle terminates.
Seen versus confirmed: what’s the difference?
Seen is every finding the finders have ever surfaced, including judge-rejected ones. Confirmed is the judge-accepted subset. A discovery cycle must dedupe against seen, because the rejected findings are exactly what the finders re-surface every round. Dedupe against confirmed and you forget every rejection the moment it happens.
What could go wrong, and what’s next
You now have a discovery cycle that stops itself: dedupe each round against every finding ever seen, keyed by a stable key(finding), and halt after two dry rounds. Two pitfalls to watch. A too-loose key silently skips real findings by colliding them, so the loop under-runs and you ship with work left undone. A too-tight key lets re-surfaced dead ends slip back in with a fresh key each round, and the loop never dries, so the cap is the only thing that saves you. Keep the cap wired regardless; it’s the backstop for the day your key is wrong. More ships next in this series.
What to read next
- Stop conditions and verification (Loop Part 3): the
max_iterationscap this post distinguishes from convergence, and why a verifiable stop condition is its own concern. - The fresh-context judge (Graph Part 6): the judge whose correct rejections become this post’s bug when you forget them.
- The reduce step is free (Graph Part 4): why the dedupe set and
key()are arrange-work you write as plain code, not a node you schedule.
{ "@context": "https://schema.org", "@type": "Article", "headline": "A Cycle That Actually Stops", "author": { "@type": "Organization", "name": "ShipWithAI" }, "datePublished": "2026-08-20", "description": "Agent loop convergence, measured: dedupe a discovery loop by everything seen, it dries in 8 rounds; by confirmed, it never does. One key() decides it.", "image": "/images/blog/graph-cycle-that-converges-cover.png", "articleSection": "tutorial", "keywords": "claude-code, automation, ai, tutorial, english", "mainEntityOfPage": "https://shipwithai.io/blog/graph-cycle-that-converges/"}{ "@context": "https://schema.org", "@type": "HowTo", "name": "A Cycle That Actually Stops", "description": "Agent loop convergence, measured: dedupe a discovery loop by everything seen, it dries in 8 rounds; by confirmed, it never does. One key() decides it.", "image": "/images/blog/graph-cycle-that-converges-cover.png", "step": [ { "@type": "HowToStep", "name": "Step 1: The loop had a ceiling, not a finish line", "text": "The Loop-era spec stopped at max_iterations: 5, and that felt like done. It's a ceiling, not a finish line. A cap protects you from a runaway when something goes wrong." }, { "@type": "HowToStep", "name": "Step 2: Dedupe against everything seen, not against confirmed", "text": "A discovery cycle re-runs its finders each round, so it re-surfaces findings it surfaced before, including ones the judge already rejected. It converges only when it dedupes against every finding it has ever seen." }, { "@type": "HowToStep", "name": "Step 3: Why the loop keeps rediscovering the same dead ends", "text": "Dedupe against confirmed, and every judge-rejected finding looks new next round, so the loop re-examines the same dead ends round after round and never runs dry. The judge is working correctly." }, { "@type": "HowToStep", "name": "Step 4: Same corpus, two dedupe keys, one line apart", "text": "Both arms run one seeded corpus with identical finders, an identical judge, an identical convergence condition (two consecutive rounds with zero new findings), and an identical cap. Only the dedupe memory differs." }, { "@type": "HowToStep", "name": "Step 5: Agent loop convergence, measured, 8 rounds versus 30", "text": "Arm SEEN reached two consecutive dry rounds at round 8 and stopped itself, having retired all 6 real findings. Arm CONFIRMED never hit two dry rounds and ran to the 30-round cap." }, { "@type": "HowToStep", "name": "Step 6: Why you need both a cap and a convergence condition", "text": "The convergence condition is the finish line; the cap is the runaway backstop for when the finish line is unreachable. Neither substitutes for the other." }, { "@type": "HowToStep", "name": "Close", "text": "You now have a discovery cycle that stops itself: dedupe each round against every finding ever seen, keyed by a stable key(finding), and halt after two dry rounds." } ]}{ "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [ { "@type": "Question", "name": "What makes an agent loop converge?", "acceptedAnswer": { "@type": "Answer", "text": "The rule for agent loop convergence is one line: dedupe every round against every finding the finders have ever surfaced, keyed by a stable key(finding), and stop after two consecutive rounds that surface nothing new. A cap alone doesn't converge; it truncates." } }, { "@type": "Question", "name": "Why does my agent loop keep finding the same issues every run?", "acceptedAnswer": { "@type": "Answer", "text": "You're almost certainly deduping against confirmed (judge-accepted) findings only. Every candidate the judge rejects stays out of that memory, so it re-enters as new next round and gets re-judged. Add every surfaced finding to the seen-set, accepted or not." } }, { "@type": "Question", "name": "Do I still need max_iterations if I have a convergence condition?", "acceptedAnswer": { "@type": "Answer", "text": "Yes. The cap is a runaway backstop for a broken key(), not a substitute for the finish line. The CONFIRMED arm shows exactly what a cap-without-convergence run costs: 30 rounds, 126 findings judged, and it only halted because the cap fired." } }, { "@type": "Question", "name": "What should key(finding) be?", "acceptedAnswer": { "@type": "Answer", "text": "A stable identity over the finding's location and its rule, or a normalized description. Too loose and distinct real findings collide onto one key and get skipped, so the loop under-runs. Too tight and a re-surfaced dead end keys differently each round, slips past the filter, and the loop never dries." } }, { "@type": "Question", "name": "Seen versus confirmed: what's the difference?", "acceptedAnswer": { "@type": "Answer", "text": "Seen is every finding the finders have ever surfaced, including judge-rejected ones. Confirmed is the judge-accepted subset. A discovery cycle must dedupe against seen, because the rejected findings are exactly what the finders re-surface every round." } } ]}{ "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "Blog", "item": "https://shipwithai.io/blog/" }, { "@type": "ListItem", "position": 2, "name": "A Cycle That Actually Stops", "item": "https://shipwithai.io/blog/graph-cycle-that-converges/" } ]}