Skip to content

Commit c3625f5

Browse files
frankbriaTest User
andauthored
feat(core): wire StallAction dispatch into ReactAgent and runtime (#401) (#425)
* feat(core): wire StallAction dispatch into ReactAgent and runtime (#401) - Add StallDetectedError exception for RETRY stall recovery path - Add stall_action parameter to ReactAgent (default: BLOCKER) - Wire configurable StallAction dispatch in react loop stall check: RETRY raises StallDetectedError, FAIL returns FAILED, BLOCKER creates blocker - Catch StallDetectedError in execute_agent() with 1 retry attempt - Add --stall-action CLI flag to work start and work batch run - Thread stall_action through conductor and subprocess calls - 24 tests covering all new integration points * fix: remove unused threading import from test file * fix: honor stall_action during verification and emit correct failure reason - Verification stall check now dispatches via StallAction (RETRY/FAIL/BLOCKER) - FAILED status from react loop correctly reports stall_detected vs max_iterations --------- Co-authored-by: Test User <test@example.com>
1 parent d5e9fff commit c3625f5

7 files changed

Lines changed: 220 additions & 33 deletions

File tree

codeframe/cli/app.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,11 @@ def work_start(
20032003
"--stall-timeout",
20042004
help="Seconds without a tool call before agent is considered stalled (0 = disabled)",
20052005
),
2006+
stall_action: str = typer.Option(
2007+
"blocker",
2008+
"--stall-action",
2009+
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
2010+
),
20062011
) -> None:
20072012
"""Start working on a task.
20082013
@@ -2068,6 +2073,7 @@ def work_start(
20682073
state = runtime.execute_agent(
20692074
workspace, run, dry_run=dry_run, debug=debug, verbose=verbose,
20702075
engine=engine, stall_timeout_s=stall_timeout,
2076+
stall_action=stall_action,
20712077
)
20722078

20732079
if state.status == AgentStatus.COMPLETED:
@@ -2874,6 +2880,11 @@ def batch_run(
28742880
"--stall-timeout",
28752881
help="Seconds without a tool call before agent is considered stalled (0 = disabled)",
28762882
),
2883+
stall_action: str = typer.Option(
2884+
"blocker",
2885+
"--stall-action",
2886+
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
2887+
),
28772888
) -> None:
28782889
"""Execute multiple tasks in batch.
28792890
@@ -2972,6 +2983,7 @@ def batch_run(
29722983
max_retries=max_retries,
29732984
engine=engine,
29742985
stall_timeout_s=stall_timeout,
2986+
stall_action=stall_action,
29752987
)
29762988

29772989
# Show summary

codeframe/core/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from codeframe.core.project import Project
44
from codeframe.core.config import Config
55
from codeframe.core.models import Task, TaskStatus, AgentMaturity
6-
from codeframe.core.stall_detector import StallAction, StallDetector
6+
from codeframe.core.stall_detector import StallAction, StallDetectedError, StallDetector
77

88
__all__ = [
99
"Project",
@@ -12,5 +12,6 @@
1212
"TaskStatus",
1313
"AgentMaturity",
1414
"StallAction",
15+
"StallDetectedError",
1516
"StallDetector",
1617
]

codeframe/core/conductor.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ class BatchRun:
458458
results: dict[str, str] = field(default_factory=dict)
459459
engine: str = "react"
460460
stall_timeout_s: int = 300
461+
stall_action: str = "blocker"
461462

462463

463464
def start_batch(
@@ -471,6 +472,7 @@ def start_batch(
471472
on_event: Optional[Callable[[str, dict], None]] = None,
472473
engine: str = "react",
473474
stall_timeout_s: int = 300,
475+
stall_action: str = "blocker",
474476
) -> BatchRun:
475477
"""Start a batch execution of multiple tasks.
476478
@@ -518,6 +520,7 @@ def start_batch(
518520
results={},
519521
engine=engine,
520522
stall_timeout_s=stall_timeout_s,
523+
stall_action=stall_action,
521524
)
522525

523526
# Save to database
@@ -924,7 +927,7 @@ def _execute_serial_resume(
924927
on_event("batch_task_started", {"task_id": task_id, "position": i + 1, "is_retry": True})
925928

926929
# Execute task via subprocess
927-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
930+
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
928931

929932
# Record result (overwrites previous result)
930933
batch.results[task_id] = result_status
@@ -1092,7 +1095,7 @@ def _execute_retries(
10921095
print(f" Previous: {previous_status}")
10931096

10941097
# Execute task
1095-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
1098+
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
10961099

10971100
# Update result
10981101
batch.results[task_id] = result_status
@@ -1259,15 +1262,15 @@ def _execute_serial(
12591262
on_event("batch_task_started", {"task_id": task_id, "position": i + 1})
12601263

12611264
# Execute task via subprocess
1262-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
1265+
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
12631266

12641267
# If task is BLOCKED, try supervisor resolution
12651268
if result_status == RunStatus.BLOCKED.value:
12661269
supervisor = get_supervisor(workspace)
12671270
if supervisor.try_resolve_blocked_task(task_id):
12681271
# Supervisor resolved the blocker - retry the task
12691272
print(" [Supervisor] Retrying task after auto-resolution...")
1270-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
1273+
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
12711274

12721275
# Record result
12731276
batch.results[task_id] = result_status
@@ -1547,15 +1550,15 @@ def _execute_single_task(
15471550
on_event("batch_task_started", {"task_id": task_id, "position": position})
15481551

15491552
# Execute task via subprocess
1550-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
1553+
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
15511554

15521555
# If task is BLOCKED, try supervisor resolution
15531556
if result_status == RunStatus.BLOCKED.value:
15541557
supervisor = get_supervisor(workspace)
15551558
if supervisor.try_resolve_blocked_task(task_id):
15561559
# Supervisor resolved the blocker - retry the task
15571560
print(" [Supervisor] Retrying task after auto-resolution...")
1558-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
1561+
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
15591562

15601563
# Record result
15611564
batch.results[task_id] = result_status
@@ -1646,7 +1649,7 @@ def execute_task(task_id: str) -> tuple[str, str]:
16461649
on_event("batch_task_started", {"task_id": task_id, "parallel": True})
16471650

16481651
# Execute via subprocess
1649-
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
1652+
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s, stall_action=batch.stall_action)
16501653

16511654
# Record result (thread-safe due to GIL for simple dict operations)
16521655
batch.results[task_id] = result_status
@@ -1704,6 +1707,7 @@ def _execute_task_subprocess(
17041707
batch_id: Optional[str] = None,
17051708
engine: str = "react",
17061709
stall_timeout_s: int = 300,
1710+
stall_action: str = "blocker",
17071711
) -> str:
17081712
"""Execute a single task via subprocess.
17091713
@@ -1715,6 +1719,7 @@ def _execute_task_subprocess(
17151719
batch_id: Optional batch ID for process tracking (enables force stop)
17161720
engine: Agent engine to use ("plan" or "react")
17171721
stall_timeout_s: Stall detection timeout in seconds (0 = disabled)
1722+
stall_action: Recovery action on stall ("blocker", "retry", or "fail")
17181723
17191724
Returns:
17201725
RunStatus value string (COMPLETED, FAILED, BLOCKED)
@@ -1725,6 +1730,7 @@ def _execute_task_subprocess(
17251730
"work", "start", task_id, "--execute",
17261731
"--engine", engine,
17271732
"--stall-timeout", str(stall_timeout_s),
1733+
"--stall-action", stall_action,
17281734
]
17291735

17301736
process = None

codeframe/core/react_agent.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from codeframe.core.context import ContextLoader, TaskContext
2424
from codeframe.core.events import EventType
2525
from codeframe.core.fix_tracker import EscalationDecision, FixAttemptTracker, FixOutcome
26+
from codeframe.core.stall_detector import StallAction, StallDetectedError
2627
from codeframe.core.stall_monitor import StallEvent, StallMonitor
2728
from codeframe.core.models import AgentPhase, CompletionEvent, ErrorEvent, ProgressEvent
2829
from codeframe.core.quick_fixes import apply_quick_fix, find_quick_fix
@@ -110,6 +111,7 @@ def __init__(
110111
max_iterations: int = 30,
111112
max_verification_retries: int = 5,
112113
stall_timeout_s: float = 300,
114+
stall_action: StallAction = StallAction.BLOCKER,
113115
event_publisher: Optional[EventPublisher] = None,
114116
dry_run: bool = False,
115117
verbose: bool = False,
@@ -123,6 +125,7 @@ def __init__(
123125
self.max_iterations = max_iterations
124126
self.max_verification_retries = max_verification_retries
125127
self._stall_timeout_s = stall_timeout_s
128+
self._stall_action = stall_action
126129
self.event_publisher = event_publisher
127130
self.dry_run = dry_run
128131
self.verbose = verbose
@@ -205,11 +208,12 @@ def run(self, task_id: str) -> AgentStatus:
205208
try:
206209
status = self._react_loop(system_prompt)
207210
if status == AgentStatus.FAILED:
211+
reason = "stall_detected" if self._stall_triggered.is_set() else "max_iterations_reached"
208212
self._emit(EventType.AGENT_FAILED, {
209213
"task_id": task_id,
210-
"reason": "max_iterations_reached",
214+
"reason": reason,
211215
})
212-
self._emit_stream_error(task_id, "max_iterations_reached")
216+
self._emit_stream_error(task_id, reason)
213217
return status
214218

215219
if status == AgentStatus.BLOCKED:
@@ -244,6 +248,9 @@ def run(self, task_id: str) -> AgentStatus:
244248
return AgentStatus.FAILED
245249
finally:
246250
self._stall_monitor.stop()
251+
except StallDetectedError:
252+
self._stall_monitor.stop()
253+
raise # Let runtime handle retry
247254
except Exception:
248255
logger.exception("ReactAgent.run() failed for task %s", task_id)
249256
self._emit(EventType.AGENT_FAILED, {
@@ -283,16 +290,30 @@ def _react_loop(self, system_prompt: str) -> AgentStatus:
283290
# Check for stall before each iteration
284291
if self._stall_triggered.is_set():
285292
stall_ctx = ""
293+
elapsed_s = 0.0
286294
if self._stall_event:
295+
elapsed_s = self._stall_event.elapsed_s
287296
stall_ctx = (
288-
f"Agent stalled: no tool call for {self._stall_event.elapsed_s:.0f}s "
297+
f"Agent stalled: no tool call for {elapsed_s:.0f}s "
289298
f"(timeout: {self._stall_event.stall_timeout_s}s)"
290299
)
291-
self._create_text_blocker(
292-
stall_ctx or "Agent stalled with no tool activity",
293-
"stall_detected",
294-
)
295-
return AgentStatus.BLOCKED
300+
301+
if self._stall_action == StallAction.RETRY:
302+
raise StallDetectedError(
303+
elapsed_s=elapsed_s,
304+
iterations=iterations,
305+
last_tool=recent_tool_signatures[-1][0] if recent_tool_signatures else "",
306+
)
307+
elif self._stall_action == StallAction.FAIL:
308+
self._verbose_print(f"[ReactAgent] Stall → FAILED: {stall_ctx}")
309+
return AgentStatus.FAILED
310+
else:
311+
# StallAction.BLOCKER (default)
312+
self._create_text_blocker(
313+
stall_ctx or "Agent stalled with no tool activity",
314+
"stall_detected",
315+
)
316+
return AgentStatus.BLOCKED
296317

297318
self._verbose_print(f"[ReactAgent] Iteration {iterations + 1}/{self.max_iterations}")
298319
self._emit(EventType.AGENT_ITERATION_STARTED, {
@@ -475,6 +496,13 @@ def _run_final_verification(
475496

476497
for attempt in range(1 + self.max_verification_retries):
477498
if self._stall_triggered.is_set():
499+
if self._stall_action == StallAction.RETRY:
500+
raise StallDetectedError(
501+
elapsed_s=self._stall_event.elapsed_s if self._stall_event else 0,
502+
iterations=0,
503+
)
504+
elif self._stall_action == StallAction.FAIL:
505+
return (False, "stall_failed")
478506
return (False, "stall_detected")
479507

480508
self._verbose_print("[ReactAgent] Running final verification...")

codeframe/core/runtime.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ def execute_agent(
598598
event_publisher: Optional["EventPublisher"] = None,
599599
engine: str = "react",
600600
stall_timeout_s: int = 300,
601+
stall_action: str = "blocker",
601602
) -> "AgentState":
602603
"""Execute a task using the agent orchestrator.
603604
@@ -673,22 +674,52 @@ def on_agent_event(event_type: str, data: dict) -> None:
673674
# ReactAgent has a simpler interface — it handles its own
674675
# retries and verification internally.
675676
from codeframe.core.react_agent import ReactAgent
677+
from codeframe.core.stall_detector import StallAction, StallDetectedError
676678

677-
react_agent = ReactAgent(
678-
workspace=workspace,
679-
llm_provider=provider,
680-
stall_timeout_s=stall_timeout_s,
681-
event_publisher=event_publisher,
682-
dry_run=dry_run,
683-
verbose=verbose,
684-
on_event=on_agent_event,
685-
debug=debug,
686-
output_logger=output_logger,
687-
fix_coordinator=fix_coordinator,
688-
)
689-
react_status = react_agent.run(run.task_id)
690-
# Wrap AgentStatus enum into AgentState dataclass for compatibility
691-
state = AgentState(status=react_status)
679+
resolved_action = StallAction(stall_action)
680+
681+
def _build_react_agent() -> ReactAgent:
682+
return ReactAgent(
683+
workspace=workspace,
684+
llm_provider=provider,
685+
stall_timeout_s=stall_timeout_s,
686+
stall_action=resolved_action,
687+
event_publisher=event_publisher,
688+
dry_run=dry_run,
689+
verbose=verbose,
690+
on_event=on_agent_event,
691+
debug=debug,
692+
output_logger=output_logger,
693+
fix_coordinator=fix_coordinator,
694+
)
695+
696+
max_stall_retries = 1
697+
for stall_attempt in range(1 + max_stall_retries):
698+
try:
699+
react_agent = _build_react_agent()
700+
react_status = react_agent.run(run.task_id)
701+
# Wrap AgentStatus enum into AgentState dataclass for compatibility
702+
state = AgentState(status=react_status)
703+
break
704+
except StallDetectedError as exc:
705+
run_logger.warning(
706+
LogCategory.AGENT_ACTION,
707+
f"Stall detected (attempt {stall_attempt + 1}): {exc}",
708+
{"elapsed_s": exc.elapsed_s, "iterations": exc.iterations},
709+
)
710+
if stall_attempt >= max_stall_retries:
711+
run_logger.error(
712+
LogCategory.AGENT_ACTION,
713+
"Max stall retries exceeded, failing task",
714+
{},
715+
)
716+
state = AgentState(status=AgentStatus.FAILED)
717+
break
718+
run_logger.info(
719+
LogCategory.AGENT_ACTION,
720+
"Retrying after stall",
721+
{"attempt": stall_attempt + 2},
722+
)
692723
else:
693724
agent = Agent(
694725
workspace=workspace,

codeframe/core/stall_detector.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ class StallAction(str, Enum):
2424
FAIL = "fail"
2525

2626

27+
class StallDetectedError(Exception):
28+
"""Raised when a stall is detected with RETRY action.
29+
30+
Propagates up to ``execute_agent()`` so the runtime can re-invoke
31+
the agent with context about why the previous attempt stalled.
32+
"""
33+
34+
def __init__(self, elapsed_s: float, iterations: int, last_tool: str = "") -> None:
35+
self.elapsed_s = elapsed_s
36+
self.iterations = iterations
37+
self.last_tool = last_tool
38+
super().__init__(
39+
f"Agent stalled after {elapsed_s:.0f}s "
40+
f"(iterations={iterations}, last_tool={last_tool!r})"
41+
)
42+
43+
2744
class StallDetector:
2845
"""Tracks elapsed time since the last recorded activity.
2946

0 commit comments

Comments
 (0)