3434from policy import APPROVAL_RATE_LIMIT , FLOOR_TIMEOUT_S , Outcome
3535from progress_writer import _generate_ulid
3636from shell import log , log_error_cw
37+ from stuck_guard import StuckGuard
3738
3839if TYPE_CHECKING :
3940 from policy import PolicyEngine
@@ -172,6 +173,27 @@ def reset_blocker_reason() -> None:
172173_reset_blocker_reason_for_tests = reset_blocker_reason
173174
174175
176+ # ABCA-662: latch of the stuck-guard's "why it's spinning" summary, refreshed by
177+ # the between-turns hook. Read by the pipeline's terminal path so a max_turns
178+ # failure can say WHY it capped ("Exceeded max turns — spinning on failing tool
179+ # calls: git push → invalid credentials") vs. a task that genuinely used its
180+ # turns. Process-lifetime (one task), last-writer-wins (the most recent window
181+ # is the most relevant to the cap). Distinct from the blocker latch: this is a
182+ # SOFT diagnostic (advisory), not a canonical BLOCKED[…] terminal reason.
183+ _LAST_STUCK_SUMMARY : str | None = None
184+
185+
186+ def last_stuck_summary () -> str | None :
187+ """Return the latched stuck-guard summary for the max_turns terminal reason."""
188+ return _LAST_STUCK_SUMMARY
189+
190+
191+ def reset_stuck_summary () -> None :
192+ """Clear the stuck-summary latch (per-task, alongside the blocker latch)."""
193+ global _LAST_STUCK_SUMMARY
194+ _LAST_STUCK_SUMMARY = None
195+
196+
175197def detect_egress_denial (text : str ) -> tuple [bool , str | None ]:
176198 """Scan tool output for an egress-denial signature (#251).
177199
@@ -1039,6 +1061,7 @@ async def post_tool_use_hook(
10391061 * ,
10401062 trajectory : _TrajectoryWriter | None = None ,
10411063 progress : Any = None ,
1064+ stuck_guard : StuckGuard | None = None ,
10421065) -> dict :
10431066 """PostToolUse hook: screen tool output for secrets/PII.
10441067
@@ -1051,6 +1074,11 @@ async def post_tool_use_hook(
10511074 present, an egress-denial signature in the tool output emits an
10521075 ``egress_denied`` blocker event (#251) — best-effort observability,
10531076 never alters the screening decision.
1077+
1078+ K7: when a ``stuck_guard`` is supplied, every tool result is recorded so a
1079+ between-turns hook can detect a repeating failing command (the ABCA-483
1080+ spin loop) and steer / bail. Recording is best-effort and never alters the
1081+ screening outcome.
10541082 """
10551083 _PASS_THROUGH : dict = {"hookSpecificOutput" : {"hookEventName" : "PostToolUse" }}
10561084 _FAIL_CLOSED : dict = {
@@ -1113,6 +1141,14 @@ async def post_tool_use_hook(
11131141 resource = host ,
11141142 )
11151143
1144+ # K7: feed the stuck-guard (best-effort — a tracking error must never block
1145+ # the screening path that follows).
1146+ if stuck_guard is not None :
1147+ try :
1148+ stuck_guard .record_tool_result (tool_name , hook_input .get ("tool_input" ), tool_response )
1149+ except Exception as exc :
1150+ log ("WARN" , f"stuck-guard record raised (ignored): { type (exc ).__name__ } : { exc } " )
1151+
11161152 try :
11171153 result = scan_tool_output (tool_response )
11181154 except Exception as exc :
@@ -1395,6 +1431,49 @@ def _cancel_between_turns_hook(ctx: dict) -> list[str]:
13951431 return []
13961432
13971433
1434+ def _stuck_guard_between_turns_hook (ctx : dict ) -> list [str ]:
1435+ """K7: nudge the agent when it repeats the SAME failing command (ABCA-483).
1436+
1437+ Reads the per-task :class:`StuckGuard` stamped on ``ctx`` (by
1438+ :func:`stop_hook`). When the same command has failed with identical output
1439+ enough times in a row, the guard returns a ``steer`` action — a ONE-TIME
1440+ advisory message telling the agent to stop retrying and either work around
1441+ the failure or finish with what it has. Returned as injected text, so the
1442+ SDK continues the turn with the steer as the next user message.
1443+
1444+ ADVISORY ONLY: the guard never kills the task (the bail path was removed —
1445+ distinguishing a true spin from a legitimately-iterating agent is too
1446+ fragile to justify an auto-kill; the ``max_turns`` cap is the real
1447+ backstop). A false positive here costs exactly one extra advisory comment.
1448+
1449+ Runs AFTER cancel (cancel wins — never steer a dying agent) but its own
1450+ no-op-when-cancelled guard makes ordering robust. Fail-open: any error is
1451+ swallowed so a guard bug can never wedge a healthy agent.
1452+ """
1453+ if ctx .get ("_cancel_requested" ):
1454+ return []
1455+ guard = ctx .get ("stuck_guard" )
1456+ if guard is None :
1457+ return []
1458+ try :
1459+ action = guard .evaluate ()
1460+ # ABCA-662: refresh the "why it's spinning" latch every turn. When the
1461+ # trailing window is failure-dominated this returns a one-liner; otherwise
1462+ # None (which clears the latch — a task that recovered isn't "stuck"). Read
1463+ # by the terminal path so a later max_turns cap explains itself.
1464+ global _LAST_STUCK_SUMMARY
1465+ _LAST_STUCK_SUMMARY = guard .recent_failure_summary ()
1466+ except Exception as exc :
1467+ log ("WARN" , f"stuck-guard evaluate raised (ignored): { type (exc ).__name__ } : { exc } " )
1468+ return []
1469+
1470+ if action .kind == "steer" :
1471+ _emit_nudge_milestone (ctx , "stuck_steer" , action .message [:_NUDGE_PREVIEW_LEN ])
1472+ log ("NUDGE" , f"stuck-guard STEER injected: { action .signature } " )
1473+ return [action .message ]
1474+ return []
1475+
1476+
13981477# Global list of between-turns hooks. Cancel MUST run first so it can
13991478# short-circuit nudges on cancelled tasks (no point injecting nudges into a
14001479# dying agent — worse, the nudge reader mutates DDB state that the agent will
@@ -1406,6 +1485,10 @@ def _cancel_between_turns_hook(ctx: dict) -> list[str]:
14061485# nudge reader to preserve cancel-wins semantics.
14071486between_turns_hooks : list [BetweenTurnsHook ] = [
14081487 _cancel_between_turns_hook ,
1488+ # K7 stuck-guard (advisory): injects a one-time "stop retrying X" nudge when
1489+ # the same command keeps failing identically. Runs after cancel (never steer
1490+ # a dying agent); order vs nudge/denial is cosmetic since it never bails.
1491+ _stuck_guard_between_turns_hook ,
14091492 _nudge_between_turns_hook ,
14101493 # Chunk 3 (finding #2): denial injection runs LAST so both cancel and
14111494 # nudge short-circuits pre-empt it. The hook explicitly re-checks
@@ -1423,6 +1506,7 @@ async def stop_hook(
14231506 task_id : str ,
14241507 progress : Any = None ,
14251508 engine : Any = None ,
1509+ stuck_guard : Any = None ,
14261510) -> dict :
14271511 """Stop hook: run registered between-turns hooks; block if they produce text.
14281512
@@ -1445,6 +1529,7 @@ async def stop_hook(
14451529 "task_id" : task_id ,
14461530 "progress" : progress ,
14471531 "engine" : engine ,
1532+ "stuck_guard" : stuck_guard ,
14481533 }
14491534
14501535 # Cancel-before-nudge short-circuit.
@@ -1526,6 +1611,11 @@ def build_hook_matchers(
15261611 SyncHookJSONOutput ,
15271612 )
15281613
1614+ # K7: one stuck-guard per task (== per build_hook_matchers call). The
1615+ # PostToolUse closure feeds it every tool result; the Stop closure reads it
1616+ # between turns to steer / bail on a repeating failing command.
1617+ _stuck_guard = StuckGuard ()
1618+
15291619 # Closure-based wrapper matches the HookCallback signature exactly:
15301620 # (HookInput, str | None, HookContext) -> Awaitable[HookJSONOutput]
15311621 async def _pre (
@@ -1568,7 +1658,12 @@ async def _post(
15681658 ) -> HookJSONOutput :
15691659 try :
15701660 result = await post_tool_use_hook (
1571- hook_input , tool_use_id , ctx , trajectory = trajectory , progress = progress
1661+ hook_input ,
1662+ tool_use_id ,
1663+ ctx ,
1664+ trajectory = trajectory ,
1665+ progress = progress ,
1666+ stuck_guard = _stuck_guard ,
15721667 )
15731668 return SyncHookJSONOutput (** result )
15741669 except Exception as exc :
@@ -1593,6 +1688,7 @@ async def _stop(
15931688 task_id = stop_task_id ,
15941689 progress = progress ,
15951690 engine = engine ,
1691+ stuck_guard = _stuck_guard ,
15961692 )
15971693 except Exception as exc :
15981694 log (
0 commit comments