Add no-progress loop breaker on the workflow runner

ober

db10ba203b90434304935f9591113e15db0dd537

diff --git a/src/jcode/core/errors.ss b/src/jcode/core/errors.ss
index b51f55c..a80e6f5 100644
--- a/src/jcode/core/errors.ss
+++ b/src/jcode/core/errors.ss
@@ -35,6 +35,9 @@
         &max-iterations max-iterations-error?
         max-iterations-error-iterations max-iterations-error-pending
         raise-max-iterations
+        &no-progress no-progress-error?
+        no-progress-error-repeats no-progress-error-signature
+        raise-no-progress
         &step-enforcement step-enforcement-error?
         step-enforcement-error-terminal step-enforcement-error-attempts
         step-enforcement-error-pending
@@ -164,6 +167,25 @@
           ") exceeded. Completed: " (join-names completed-list)
           ", Pending: " (join-names pending-list))))))
 
+;; NoProgressError — model repeated an identical *executed* tool-call batch
+;; max-repeated-calls times in a row. A degenerate loop the other budgets miss:
+;; the calls neither error (error-tracker) nor hit the terminal (step-enforcer),
+;; so without this breaker they silently burn the whole iteration budget.
+(define-condition-type &no-progress &forge-error
+  make-no-progress no-progress-error?
+  (repeats no-progress-error-repeats)
+  (signature no-progress-error-signature))
+
+(def (raise-no-progress repeats signature completed-list)
+  (raise
+    (condition
+      (make-no-progress repeats signature)
+      (make-who-condition 'workflow-runner)
+      (make-message-condition
+        (string-append "No progress: the same tool call repeated "
+          (number->string repeats) " times in a row without advancing. "
+          "Completed steps: " (join-names completed-list))))))
+
 ;; StepEnforcementError — model called the terminal tool prematurely too often.
 (define-condition-type &step-enforcement &forge-error
   make-step-enforcement step-enforcement-error?
diff --git a/src/jcode/core/workflow-runner.ss b/src/jcode/core/workflow-runner.ss
index 71d4631..13f4c87 100644
--- a/src/jcode/core/workflow-runner.ss
+++ b/src/jcode/core/workflow-runner.ss
@@ -165,18 +165,34 @@
                                  (string-append prefix (nudge-content nudge)))))
       tc-data)))
 
+;; Stable string signature of a tool-call batch: tool name + serialized args per
+;; call. Two batches with the same names and args produce the same string, so an
+;; identical batch repeated across iterations is detectable by `equal?`.
+(def (tool-calls-signature tcs)
+  (call-with-string-output-port
+    (lambda (p)
+      (for-each
+        (lambda (tc)
+          (display (wtool-call-tool tc) p)
+          (display "|" p)
+          (write (wtool-call-args tc) p)
+          (display ";" p))
+        tcs))))
+
 (def (run-workflow workflow user-message responder . opt)
   "Execute WORKFLOW with USER-MESSAGE, driving the loop through RESPONDER.
    Returns the terminal tool's value. OPT is an optional options assoc:
      max-iterations (10) max-retries-per-step (3) max-tool-errors (2)
-     on-message (#f) prompt-vars ('()) initial-messages (#f)
-     cancel? (thunk -> bool, default never).
+     max-repeated-calls (#f = off) on-message (#f) prompt-vars ('())
+     initial-messages (#f) cancel? (thunk -> bool, default never).
    Raises MaxIterationsError / StepEnforcementError / PrerequisiteError /
-   ToolExecutionError / WorkflowCancelledError on the corresponding conditions."
+   ToolExecutionError / WorkflowCancelledError / NoProgressError on the
+   corresponding conditions."
   (let* ((o              (if (pair? opt) (car opt) '()))
          (max-iterations (or (opt-ref o 'max-iterations) 10))
          (max-retries    (or (opt-ref o 'max-retries-per-step) 3))
          (max-tool-errors (or (opt-ref o 'max-tool-errors) 2))
+         (max-repeated-calls (opt-ref o 'max-repeated-calls))
          (on-message     (opt-ref o 'on-message))
          (prompt-vars    (or (opt-ref o 'prompt-vars) '()))
          (initial-msgs   (opt-ref o 'initial-messages))
@@ -195,7 +211,9 @@
                                               (workflow-terminal-tools workflow)
                                               tool-prereqs))
            (error-tracker (make-error-tracker max-retries max-tool-errors))
-           (tool-specs    (workflow-get-tool-specs workflow)))
+           (tool-specs    (workflow-get-tool-specs workflow))
+           (last-sig      (vector #f))   ; signature of the previous executed batch
+           (repeat-n      (vector 0)))   ; consecutive identical-batch count
       ;; Step 3 — main loop (one responder call per iteration)
       (let loop ((iteration 0))
         (cond
@@ -241,7 +259,22 @@
                                                (step-check-nudge prereq-check))
                             (loop (+ iteration 1)))
                            (else
-                            ;; 3c → 3e — execute the batch
+                            ;; 3c — no-progress breaker (opt-in via
+                            ;; max-repeated-calls). An identical *executed* batch
+                            ;; repeated that many times in a row is a degenerate
+                            ;; loop the error/step budgets miss (the calls neither
+                            ;; error nor finish), so break early instead of
+                            ;; silently burning the iteration budget.
+                            (when max-repeated-calls
+                              (let ((sig (tool-calls-signature tool-calls)))
+                                (if (equal? sig (vector-ref last-sig 0))
+                                  (vector-set! repeat-n 0 (+ (vector-ref repeat-n 0) 1))
+                                  (begin (vector-set! last-sig 0 sig)
+                                         (vector-set! repeat-n 0 1)))
+                                (when (>= (vector-ref repeat-n 0) max-repeated-calls)
+                                  (raise-no-progress max-repeated-calls sig
+                                    (step-enforcer-completed enforcer)))))
+                            ;; 3d → 3e — execute the batch
                             (let ((outcome (execute-batch! emit! workflow enforcer
                                                            error-tracker tool-calls)))
                               (if (and (pair? outcome) (eq? (car outcome) 'terminal))
diff --git a/src/jcode/eval/runner.ss b/src/jcode/eval/runner.ss
index 791d034..4c98f1d 100644
--- a/src/jcode/eval/runner.ss
+++ b/src/jcode/eval/runner.ss
@@ -39,6 +39,7 @@
   (cond
     ((not (condition? e)) "error")
     ((max-iterations-error? e)    "max_iterations")
+    ((no-progress-error? e)       "no_progress")
     ((step-enforcement-error? e)  "step_enforcement")
     ((prerequisite-error? e)      "prerequisite")
     ((tool-execution-error? e)    "tool_execution")
diff --git a/src/jcode/ui/cli.ss b/src/jcode/ui/cli.ss
index 8a15f04..8e990a5 100644
--- a/src/jcode/ui/cli.ss
+++ b/src/jcode/ui/cli.ss
@@ -569,6 +569,52 @@ EXAMPLES:
   (printf "With k=1 the lone buggy candidate is rejected; k=2 resamples and the~n")
   (printf "verify-gate selects correct code. Composes with /forge verify.~n"))
 
+;; /forge breaker — ATLAS's cheap no-progress loop breaker on forge's runner.
+;; A model stuck repeating one *executed* tool call trips neither the error
+;; budget (the call succeeds) nor step enforcement (no terminal), so without a
+;; breaker it burns the whole iteration budget. The opt-in max-repeated-calls
+;; breaker detects the identical-batch loop and stops early.
+(def (forge-breaker-stuck-wf)
+  (make-workflow "stuck" "Look, then answer."
+    (list
+      (make-tool-def (make-tool-spec "look" "Look around." '(("type" . "object")))
+                     (lambda (args) "looked") '())
+      (make-tool-def (make-tool-spec "answer" "Answer." '(("type" . "object")))
+                     (lambda (args) "ANSWER") '()))
+    '() "answer" "p"))
+
+;; Run a stuck look-loop with the given breaker threshold (#f = off). Returns
+;; (error-type . iterations-used).
+(def (forge-breaker-run max-repeated)
+  (let ((calls (vector 0))
+        (n     (vector 0))
+        (script (make-list 12 (list (make-wtool-call "look" '() "looking")))))
+    (let ((resp (lambda (messages tool-specs step)
+                  (vector-set! calls 0 (+ (vector-ref calls 0) 1))
+                  (let ((r (if (< (vector-ref n 0) (length script))
+                             (list-ref script (vector-ref n 0))
+                             (make-text-response "stuck"))))
+                    (vector-set! n 0 (+ (vector-ref n 0) 1)) r)))
+          (opts (if max-repeated
+                  (list (cons 'max-iterations 8) (cons 'max-repeated-calls max-repeated))
+                  (list (cons 'max-iterations 8)))))
+      (guard (e [#t (cons (eval-error-type e) (vector-ref calls 0))])
+        (run-workflow (forge-breaker-stuck-wf) "explore" resp opts)
+        (cons "completed" (vector-ref calls 0))))))
+
+(def (forge-print-breaker)
+  (printf "No-progress breaker: ATLAS's cheap loop breaker on forge's runner.~n")
+  (printf "  an identical executed tool-call batch repeated max-repeated-calls~n")
+  (printf "  times in a row breaks early — the error/step budgets never catch it.~n")
+  (let ((off (forge-breaker-run #f))
+        (on  (forge-breaker-run 3)))
+    (printf "  off            error=~a iterations=~a (burned the budget)~n"
+            (car off) (cdr off))
+    (printf "  max-repeated 3 error=~a iterations=~a (stopped early)~n"
+            (car on) (cdr on)))
+  (printf "Only identical *executed* batches count, so a real edit/verify repair~n")
+  (printf "cycle (alternating tools) is never affected. Composes with best-of-k.~n"))
+
 ;; `jcode proxy` — serve the configured provider behind the guardrail proxy.
 (def (proxy-main args)
   (let loop ((args args) (port 8080) (bind "127.0.0.1"))
@@ -597,7 +643,7 @@ EXAMPLES:
   (let ((cmd (string-trim (substring input 1 (string-length input)))))
     (cond
       ((equal? cmd "help")
-       (display "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /plan              Switch to PLAN mode (read-only)\n  /build             Switch to BUILD mode (read+write)\n  /mode              Show current mode\n  /mcp               Toggle MCP tools on/off\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /undo [N]          Revert last N checkpoint(s) (default 1)\n  /checkpoints       List recent shadow-git checkpoints\n  /forge [on|off]    Show or toggle forge guardrails\n  /forge sampling <off|on|strict>  Per-model sampling policy\n  /forge workflow    Describe + self-test the workflow engine\n  /forge proxy       Describe + self-test the OpenAI-compatible proxy\n  /forge ablation    Describe + self-test the eval/ablation harness\n  /forge verify      Describe + self-test the verify-gate (ATLAS verify+repair)\n  /forge bestofk     Describe + self-test best-of-k diverse-gen (ATLAS Phase-1)\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n"))
+       (display "\nCommands:\n  /help              Show this help\n  /model [name]      Show or set model\n  /provider [name]   Show or set provider\n  /plan              Switch to PLAN mode (read-only)\n  /build             Switch to BUILD mode (read+write)\n  /mode              Show current mode\n  /mcp               Toggle MCP tools on/off\n  /tools             List available tools\n  /clear             Start a new session\n  /sessions          List saved sessions\n  /compact           Show message count\n  /undo [N]          Revert last N checkpoint(s) (default 1)\n  /checkpoints       List recent shadow-git checkpoints\n  /forge [on|off]    Show or toggle forge guardrails\n  /forge sampling <off|on|strict>  Per-model sampling policy\n  /forge workflow    Describe + self-test the workflow engine\n  /forge proxy       Describe + self-test the OpenAI-compatible proxy\n  /forge ablation    Describe + self-test the eval/ablation harness\n  /forge verify      Describe + self-test the verify-gate (ATLAS verify+repair)\n  /forge bestofk     Describe + self-test best-of-k diverse-gen (ATLAS Phase-1)\n  /forge breaker     Describe + self-test the no-progress loop breaker\n  /quit              Exit\n\nMulti-line: end a line with \\ to continue on the next line.\n\n"))
       ((equal? cmd "model")
        (printf "Provider: ~a~n" (or (current-provider-override) (config-provider)))
        (printf "Model:    ~a~n" (or (current-model-override) (config-model)))
@@ -694,6 +740,8 @@ EXAMPLES:
        (forge-print-verify))
       ((or (equal? cmd "forge bestofk") (equal? cmd "forge best-of-k"))
        (forge-print-bestofk))
+      ((or (equal? cmd "forge breaker") (equal? cmd "forge no-progress"))
+       (forge-print-breaker))
       ((or (equal? cmd "forge on") (equal? cmd "forge enforce")
            (equal? cmd "forge enforce on"))
        (forge-respond-enforced? #t)
diff --git a/test/run.ss b/test/run.ss
index 825c8d0..28ff4a8 100644
--- a/test/run.ss
+++ b/test/run.ss
@@ -1438,6 +1438,53 @@
   (raises? (lambda () (run-best-of-k (bok-workflow (vector "UNWRITTEN"))
                                      "go" (bok-factory) 0 bok-opts))) #t)
 
+(section "=== no-progress breaker: ATLAS cheap loop breaker ===")
+;; A model stuck calling the same executed tool forever. The error budget never
+;; trips (the call succeeds) and the terminal is never reached, so without a
+;; breaker it silently burns the whole iteration budget → MaxIterationsError.
+;; The opt-in max-repeated-calls breaker detects the identical-batch loop and
+;; breaks early with NoProgressError, wasting far less compute. (look has no
+;; prerequisite and is not a required step, so each call genuinely executes.)
+(define (mk-stuck-wf)
+  (make-workflow "stuck" "Look, then answer."
+    (list
+      (make-tool-def (make-tool-spec "look" "l" '(("type" . "object"))) (lambda (a) "looked") '())
+      (make-tool-def (make-tool-spec "answer" "a" '(("type" . "object"))) (lambda (a) "ANSWER") '()))
+    '() "answer" "p"))
+;; Without the breaker: 6 identical looks burn all 6 iterations → MaxIterations.
+(let* ([w     (mk-stuck-wf)]
+       [calls (vector 0)]
+       [base  (scripted-responder (make-list 10 (list (make-wtool-call "look" '() #f))))]
+       [resp  (lambda (m s i) (vector-set! calls 0 (+ (vector-ref calls 0) 1)) (base m s i))])
+  (check! "no breaker: stuck loop raises MaxIterationsError"
+    (raises-pred? (lambda () (run-workflow w "go" resp (list (cons 'max-iterations 6))))
+                  max-iterations-error?) #t)
+  (check! "no breaker burned all 6 iterations" (vector-ref calls 0) 6))
+;; With the breaker (3): breaks at the 3rd identical executed batch → NoProgress.
+(let* ([w     (mk-stuck-wf)]
+       [calls (vector 0)]
+       [base  (scripted-responder (make-list 10 (list (make-wtool-call "look" '() #f))))]
+       [resp  (lambda (m s i) (vector-set! calls 0 (+ (vector-ref calls 0) 1)) (base m s i))])
+  (check! "breaker: stuck loop raises NoProgressError"
+    (raises-pred? (lambda () (run-workflow w "go" resp
+                               (list (cons 'max-iterations 6) (cons 'max-repeated-calls 3))))
+                  no-progress-error?) #t)
+  (check! "breaker fired at 3 calls, not 6 (half the wasted compute)"
+    (vector-ref calls 0) 3))
+;; The breaker only counts identical *executed* batches: a real edit→verify→edit
+;; repair cycle alternates tools, so it never trips even with a tight threshold.
+(let* ([code (vector "UNWRITTEN")]
+       [w    (bok-workflow code)]
+       [resp (scripted-responder
+               (list (list (make-wtool-call "edit" '(("content" . "buggy")) #f))
+                     (list (make-wtool-call "verify" '() #f))
+                     (list (make-wtool-call "edit" '(("content" . "correct")) #f))
+                     (list (make-wtool-call "verify" '() #f))
+                     (list (make-wtool-call "done" '(("summary" . "ok")) #f))))]
+       [result (run-workflow w "go" resp (list (cons 'max-iterations 8)
+                                               (cons 'max-repeated-calls 2)))])
+  (check! "breaker leaves real edit/verify repair alone" result "SHIPPED"))
+
 ;; ── Results ───────────────────────────────────────────────────────
 
 (printf "~n~a passed, ~a failed~n" pass-count fail-count)