Skip to content

Commit 2bbb18b

Browse files
authored
Merge pull request #13 from Agent-Field/fix/stuck-loop-convergence
fix: prevent non-convergent stuck loops on trivial tasks
2 parents 1ac676c + 3494a02 commit 2bbb18b

2 files changed

Lines changed: 850 additions & 18 deletions

File tree

swe_af/execution/coding_loop.py

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,27 @@ async def _write_memory_on_failure(
251251
await _memory_set(memory_fn, "build_health", health)
252252

253253

254+
# ---------------------------------------------------------------------------
255+
# Stuck-loop detection
256+
# ---------------------------------------------------------------------------
257+
258+
259+
def _detect_stuck_loop(iteration_history: list[dict], window: int = 3) -> bool:
260+
"""Return True if the last ``window`` iterations are all non-blocking "fix" cycles.
261+
262+
This catches the default-path failure mode where the reviewer repeatedly
263+
returns approved=False / blocking=False with similar feedback, causing the
264+
coder to re-attempt the same work without converging.
265+
"""
266+
if len(iteration_history) < window:
267+
return False
268+
recent = iteration_history[-window:]
269+
return all(
270+
entry.get("action") == "fix" and not entry.get("review_blocking", False)
271+
for entry in recent
272+
)
273+
274+
254275
# ---------------------------------------------------------------------------
255276
# Path routing helpers
256277
# ---------------------------------------------------------------------------
@@ -638,6 +659,7 @@ async def run_coding_loop(
638659
qa_result = None
639660
synthesis_result = None
640661
_save_artifact(dag_state.artifacts_dir, iteration_id, "review", review_result)
662+
641663
stuck = False
642664

643665
# Record iteration for history
@@ -733,35 +755,85 @@ async def run_coding_loop(
733755
else:
734756
feedback = summary
735757

736-
# Stuck detection
758+
# Stuck detection — default path uses history-based detection since it
759+
# has no synthesizer to set the stuck flag.
760+
if not stuck and not needs_deeper_qa:
761+
stuck = _detect_stuck_loop(iteration_history)
762+
737763
if stuck:
738-
if note_fn:
739-
note_fn(
740-
f"Coding loop STUCK: {issue_name} — breaking after {iteration} iterations",
741-
tags=["coding_loop", "stuck", issue_name],
764+
last_blocking = review_result.get("blocking", False) if review_result else False
765+
if not last_blocking and files_changed:
766+
# Non-blocking stuck loop with code changes → accept with debt
767+
if note_fn:
768+
note_fn(
769+
f"Coding loop STUCK (non-blocking): {issue_name} — "
770+
f"accepting with debt after {iteration} iterations",
771+
tags=["coding_loop", "stuck", "accept_debt", issue_name],
772+
)
773+
return IssueResult(
774+
issue_name=issue_name,
775+
outcome=IssueOutcome.COMPLETED_WITH_DEBT,
776+
result_summary=f"Accepted with debt (stuck loop, non-blocking): {summary}",
777+
files_changed=files_changed,
778+
branch_name=branch_name,
779+
attempts=iteration,
780+
iteration_history=iteration_history,
742781
)
743-
await _write_memory_on_failure(
744-
memory_fn, issue, summary, review_result, note_fn,
745-
)
746-
return IssueResult(
747-
issue_name=issue_name,
748-
outcome=IssueOutcome.FAILED_UNRECOVERABLE,
749-
error_message=f"Stuck loop detected: {summary}",
750-
files_changed=files_changed,
751-
branch_name=branch_name,
752-
attempts=iteration,
753-
iteration_history=iteration_history,
782+
else:
783+
if note_fn:
784+
note_fn(
785+
f"Coding loop STUCK: {issue_name} — breaking after {iteration} iterations",
786+
tags=["coding_loop", "stuck", issue_name],
787+
)
788+
await _write_memory_on_failure(
789+
memory_fn, issue, summary, review_result, note_fn,
790+
)
791+
return IssueResult(
792+
issue_name=issue_name,
793+
outcome=IssueOutcome.FAILED_UNRECOVERABLE,
794+
error_message=f"Stuck loop detected: {summary}",
795+
files_changed=files_changed,
796+
branch_name=branch_name,
797+
attempts=iteration,
798+
iteration_history=iteration_history,
799+
)
800+
801+
# Loop exhausted without approval — check if we can accept with debt
802+
last_review = review_result if 'review_result' in dir() else None
803+
last_blocking = (last_review.get("blocking", False) if last_review else False)
804+
805+
if not last_blocking and files_changed:
806+
# Reviewer was never blocking and coder produced changes — accept with debt
807+
# rather than failing entirely. This prevents trivial tasks from stalling
808+
# the whole DAG when the reviewer keeps requesting minor polish.
809+
if note_fn:
810+
note_fn(
811+
f"Coding loop exhausted (non-blocking): {issue_name} — "
812+
f"accepting with debt after {max_iterations} iterations",
813+
tags=["coding_loop", "exhausted", "accept_debt", issue_name],
754814
)
815+
return IssueResult(
816+
issue_name=issue_name,
817+
outcome=IssueOutcome.COMPLETED_WITH_DEBT,
818+
result_summary=(
819+
f"Accepted with debt after {max_iterations} iterations "
820+
f"(reviewer non-blocking, code changes present)"
821+
),
822+
files_changed=files_changed,
823+
branch_name=branch_name,
824+
attempts=max_iterations,
825+
iteration_history=iteration_history,
826+
)
755827

756-
# Loop exhausted without approval
828+
# Truly unrecoverable — reviewer was blocking or no code was produced
757829
if note_fn:
758830
note_fn(
759831
f"Coding loop exhausted: {issue_name} after {max_iterations} iterations",
760832
tags=["coding_loop", "exhausted", issue_name],
761833
)
762834

763835
await _write_memory_on_failure(
764-
memory_fn, issue, "Loop exhausted", review_result if 'review_result' in dir() else None, note_fn,
836+
memory_fn, issue, "Loop exhausted", last_review, note_fn,
765837
)
766838

767839
return IssueResult(

0 commit comments

Comments
 (0)