@@ -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