Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,11 @@ def work_start(
"--stall-timeout",
help="Seconds without a tool call before agent is considered stalled (0 = disabled)",
),
stall_action: str = typer.Option(
"blocker",
"--stall-action",
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
),
Comment on lines +2006 to +2010

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and inspect the relevant lines in codeframe/cli/app.py
fd -n "app.py" | grep "cli"

Repository: frankbria/codeframe

Length of output: 293


🏁 Script executed:

# Get the actual code at lines 2006-2010 and surrounding context
sed -n '2000,2020p' codeframe/cli/app.py

Repository: frankbria/codeframe

Length of output: 745


🏁 Script executed:

# Check if StallAction exists in codeframe/core/stall_detector
fd -n "stall_detector.py" codeframe/

Repository: frankbria/codeframe

Length of output: 293


🏁 Script executed:

# Find stall_detector.py
find codeframe -name "stall_detector.py" -type f

Repository: frankbria/codeframe

Length of output: 95


🏁 Script executed:

# Check batch_run() at lines 2883-2887
sed -n '2880,2900p' codeframe/cli/app.py

Repository: frankbria/codeframe

Length of output: 923


🏁 Script executed:

# Check work_start() function to see the line 2055 context and state mutation concern
sed -n '2050,2060p' codeframe/cli/app.py

Repository: frankbria/codeframe

Length of output: 507


🏁 Script executed:

# Examine the StallAction enum in stall_detector.py
cat codeframe/core/stall_detector.py | head -100

Repository: frankbria/codeframe

Length of output: 2386


🏁 Script executed:

# Find where stall_action is validated/used in the codebase
rg "stall_action" -A 2 -B 2 codeframe/

Repository: frankbria/codeframe

Length of output: 8462


🏁 Script executed:

# Check the full work_start() function signature and usage of stall_action
sed -n '1990,2070p' codeframe/cli/app.py

Repository: frankbria/codeframe

Length of output: 3079


🏁 Script executed:

# Check if there are existing Typer+Enum patterns in the codebase
rg "typer.Option" -A 3 codeframe/cli/app.py | grep -E "(Enum|enum|\[.*\])" -B 3

Repository: frankbria/codeframe

Length of output: 6119


🏁 Script executed:

# Check what's imported at top of codeframe/cli/app.py
head -60 codeframe/cli/app.py | grep -E "^import|^from"

Repository: frankbria/codeframe

Length of output: 333


🏁 Script executed:

# Verify: does work_start() call execute_agent with stall_action and what happens to it?
sed -n '2065,2075p' codeframe/cli/app.py

Repository: frankbria/codeframe

Length of output: 685


🏁 Script executed:

# Check what batch_run() does with stall_action - does it create state before validation?
sed -n '2850,2950p' codeframe/cli/app.py | head -100

Repository: frankbria/codeframe

Length of output: 4100


🏁 Script executed:

# Check if Typer supports Enum type hints by searching for documentation patterns
rg "from enum import" codeframe/cli/

Repository: frankbria/codeframe

Length of output: 45


🏁 Script executed:

# Final check: verify the exact conversion logic in runtime.execute_agent()
rg "StallAction\(" -B 2 -A 2 codeframe/core/runtime.py

Repository: frankbria/codeframe

Length of output: 258


🏁 Script executed:

# Check if conductor.start_batch() creates persistent state immediately
rg "def start_batch" -A 15 codeframe/core/conductor.py | head -30

Repository: frankbria/codeframe

Length of output: 504


🏁 Script executed:

# Verify: is BatchRun a database model that gets persisted on creation?
rg "class BatchRun" -A 10 codeframe/core/

Repository: frankbria/codeframe

Length of output: 800


Use StallAction enum type for parse-time validation of --stall-action option.

The CLI currently accepts arbitrary strings and validates them only during execute_agent() via StallAction(stall_action) conversion. In work_start(), this creates a timing gap: the run is created at line 2055 before validation occurs, so an invalid --stall-action value leaves behind a dangling run in IN_PROGRESS state.

Suggested change
+from codeframe.core.stall_detector import StallAction
@@
-    stall_action: str = typer.Option(
-        "blocker",
+    stall_action: StallAction = typer.Option(
+        StallAction.BLOCKER,
         "--stall-action",
         help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
     ),

Apply to both work_start() and batch_run().

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
stall_action: str = typer.Option(
"blocker",
"--stall-action",
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
),
from codeframe.core.stall_detector import StallAction
stall_action: StallAction = typer.Option(
StallAction.BLOCKER,
"--stall-action",
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/cli/app.py` around lines 2006 - 2010, The CLI option currently
defined as stall_action: str should use the StallAction enum for parse-time
validation: change the typer.Option parameter type to StallAction (e.g.,
stall_action: StallAction = typer.Option(StallAction.blocker, "--stall-action",
help=...)) in both work_start() and batch_run(), and remove the later runtime
conversion StallAction(stall_action) in execute_agent() so invalid values are
rejected at parsing and no IN_PROGRESS run is created; keep the same help text
but set the default to StallAction.blocker and update any downstream usage sites
that expect a string to accept the StallAction value (or call .value where a
string is required).

) -> None:
"""Start working on a task.

Expand Down Expand Up @@ -2068,6 +2073,7 @@ def work_start(
state = runtime.execute_agent(
workspace, run, dry_run=dry_run, debug=debug, verbose=verbose,
engine=engine, stall_timeout_s=stall_timeout,
stall_action=stall_action,
)

if state.status == AgentStatus.COMPLETED:
Expand Down Expand Up @@ -2874,6 +2880,11 @@ def batch_run(
"--stall-timeout",
help="Seconds without a tool call before agent is considered stalled (0 = disabled)",
),
stall_action: str = typer.Option(
"blocker",
"--stall-action",
help="Recovery action on stall: 'blocker' (default), 'retry', or 'fail'",
),
) -> None:
"""Execute multiple tasks in batch.

Expand Down Expand Up @@ -2972,6 +2983,7 @@ def batch_run(
max_retries=max_retries,
engine=engine,
stall_timeout_s=stall_timeout,
stall_action=stall_action,
)

# Show summary
Expand Down
3 changes: 2 additions & 1 deletion codeframe/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from codeframe.core.project import Project
from codeframe.core.config import Config
from codeframe.core.models import Task, TaskStatus, AgentMaturity
from codeframe.core.stall_detector import StallAction, StallDetector
from codeframe.core.stall_detector import StallAction, StallDetectedError, StallDetector

__all__ = [
"Project",
Expand All @@ -12,5 +12,6 @@
"TaskStatus",
"AgentMaturity",
"StallAction",
"StallDetectedError",
"StallDetector",
]
20 changes: 13 additions & 7 deletions codeframe/core/conductor.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ class BatchRun:
results: dict[str, str] = field(default_factory=dict)
engine: str = "react"
stall_timeout_s: int = 300
stall_action: str = "blocker"


def start_batch(
Expand All @@ -471,6 +472,7 @@ def start_batch(
on_event: Optional[Callable[[str, dict], None]] = None,
engine: str = "react",
stall_timeout_s: int = 300,
stall_action: str = "blocker",
) -> BatchRun:
"""Start a batch execution of multiple tasks.

Expand Down Expand Up @@ -518,6 +520,7 @@ def start_batch(
results={},
engine=engine,
stall_timeout_s=stall_timeout_s,
stall_action=stall_action,
)

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

# Execute task via subprocess
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
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)

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

# Execute task
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
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)

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

# Execute task via subprocess
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
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)

# If task is BLOCKED, try supervisor resolution
if result_status == RunStatus.BLOCKED.value:
supervisor = get_supervisor(workspace)
if supervisor.try_resolve_blocked_task(task_id):
# Supervisor resolved the blocker - retry the task
print(" [Supervisor] Retrying task after auto-resolution...")
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
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)

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

# Execute task via subprocess
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
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)

# If task is BLOCKED, try supervisor resolution
if result_status == RunStatus.BLOCKED.value:
supervisor = get_supervisor(workspace)
if supervisor.try_resolve_blocked_task(task_id):
# Supervisor resolved the blocker - retry the task
print(" [Supervisor] Retrying task after auto-resolution...")
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
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)

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

# Execute via subprocess
result_status = _execute_task_subprocess(workspace, task_id, batch.id, engine=batch.engine, stall_timeout_s=batch.stall_timeout_s)
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)

# Record result (thread-safe due to GIL for simple dict operations)
batch.results[task_id] = result_status
Expand Down Expand Up @@ -1704,6 +1707,7 @@ def _execute_task_subprocess(
batch_id: Optional[str] = None,
engine: str = "react",
stall_timeout_s: int = 300,
stall_action: str = "blocker",
) -> str:
"""Execute a single task via subprocess.

Expand All @@ -1715,6 +1719,7 @@ def _execute_task_subprocess(
batch_id: Optional batch ID for process tracking (enables force stop)
engine: Agent engine to use ("plan" or "react")
stall_timeout_s: Stall detection timeout in seconds (0 = disabled)
stall_action: Recovery action on stall ("blocker", "retry", or "fail")

Returns:
RunStatus value string (COMPLETED, FAILED, BLOCKED)
Expand All @@ -1725,6 +1730,7 @@ def _execute_task_subprocess(
"work", "start", task_id, "--execute",
"--engine", engine,
"--stall-timeout", str(stall_timeout_s),
"--stall-action", stall_action,
]

process = None
Expand Down
44 changes: 36 additions & 8 deletions codeframe/core/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from codeframe.core.context import ContextLoader, TaskContext
from codeframe.core.events import EventType
from codeframe.core.fix_tracker import EscalationDecision, FixAttemptTracker, FixOutcome
from codeframe.core.stall_detector import StallAction, StallDetectedError
from codeframe.core.stall_monitor import StallEvent, StallMonitor
from codeframe.core.models import AgentPhase, CompletionEvent, ErrorEvent, ProgressEvent
from codeframe.core.quick_fixes import apply_quick_fix, find_quick_fix
Expand Down Expand Up @@ -110,6 +111,7 @@ def __init__(
max_iterations: int = 30,
max_verification_retries: int = 5,
stall_timeout_s: float = 300,
stall_action: StallAction = StallAction.BLOCKER,
event_publisher: Optional[EventPublisher] = None,
dry_run: bool = False,
verbose: bool = False,
Expand All @@ -123,6 +125,7 @@ def __init__(
self.max_iterations = max_iterations
self.max_verification_retries = max_verification_retries
self._stall_timeout_s = stall_timeout_s
self._stall_action = stall_action
self.event_publisher = event_publisher
self.dry_run = dry_run
self.verbose = verbose
Expand Down Expand Up @@ -205,11 +208,12 @@ def run(self, task_id: str) -> AgentStatus:
try:
status = self._react_loop(system_prompt)
if status == AgentStatus.FAILED:
reason = "stall_detected" if self._stall_triggered.is_set() else "max_iterations_reached"
self._emit(EventType.AGENT_FAILED, {
"task_id": task_id,
"reason": "max_iterations_reached",
"reason": reason,
})
self._emit_stream_error(task_id, "max_iterations_reached")
self._emit_stream_error(task_id, reason)
return status

if status == AgentStatus.BLOCKED:
Expand Down Expand Up @@ -244,6 +248,9 @@ def run(self, task_id: str) -> AgentStatus:
return AgentStatus.FAILED
finally:
self._stall_monitor.stop()
except StallDetectedError:
self._stall_monitor.stop()
raise # Let runtime handle retry
except Exception:
logger.exception("ReactAgent.run() failed for task %s", task_id)
self._emit(EventType.AGENT_FAILED, {
Expand Down Expand Up @@ -283,16 +290,30 @@ def _react_loop(self, system_prompt: str) -> AgentStatus:
# Check for stall before each iteration
if self._stall_triggered.is_set():
stall_ctx = ""
elapsed_s = 0.0
if self._stall_event:
elapsed_s = self._stall_event.elapsed_s
stall_ctx = (
f"Agent stalled: no tool call for {self._stall_event.elapsed_s:.0f}s "
f"Agent stalled: no tool call for {elapsed_s:.0f}s "
f"(timeout: {self._stall_event.stall_timeout_s}s)"
)
self._create_text_blocker(
stall_ctx or "Agent stalled with no tool activity",
"stall_detected",
)
return AgentStatus.BLOCKED

if self._stall_action == StallAction.RETRY:
raise StallDetectedError(
elapsed_s=elapsed_s,
iterations=iterations,
last_tool=recent_tool_signatures[-1][0] if recent_tool_signatures else "",
)
elif self._stall_action == StallAction.FAIL:
self._verbose_print(f"[ReactAgent] Stall → FAILED: {stall_ctx}")
return AgentStatus.FAILED
else:
# StallAction.BLOCKER (default)
self._create_text_blocker(
stall_ctx or "Agent stalled with no tool activity",
"stall_detected",
)
return AgentStatus.BLOCKED
Comment on lines +293 to +316

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep stall failures distinct from max-iteration failures.

The FAIL branch returns only AgentStatus.FAILED, but Line 211 later emits reason="max_iterations_reached" for any failed _react_loop() result. That misreports stalled runs, and the BLOCKER branch also drops the iteration / last-tool / token diagnostics this feature is supposed to preserve.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/core/react_agent.py` around lines 292 - 315, The FAIL branch
currently returns AgentStatus.FAILED which causes callers to report
max_iterations_reached; change the FAIL branch to raise StallDetectedError (same
shape as the RETRY branch) with elapsed_s, iterations, and last_tool so stalled
runs are distinguishable; also ensure the BLOCKER branch preserves diagnostics
by passing iterations, last_tool and elapsed_s into the blocker (e.g., include
them in the stall_ctx passed to _create_text_blocker or otherwise attach them)
so iteration/last-tool/token diagnostics are not lost (refer to symbols:
StallAction.FAIL, StallAction.BLOCKER, StallDetectedError, _create_text_blocker,
and recent_tool_signatures).


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

for attempt in range(1 + self.max_verification_retries):
if self._stall_triggered.is_set():
if self._stall_action == StallAction.RETRY:
raise StallDetectedError(
elapsed_s=self._stall_event.elapsed_s if self._stall_event else 0,
iterations=0,
)
elif self._stall_action == StallAction.FAIL:
return (False, "stall_failed")
return (False, "stall_detected")

self._verbose_print("[ReactAgent] Running final verification...")
Expand Down
61 changes: 46 additions & 15 deletions codeframe/core/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ def execute_agent(
event_publisher: Optional["EventPublisher"] = None,
engine: str = "react",
stall_timeout_s: int = 300,
stall_action: str = "blocker",
) -> "AgentState":
"""Execute a task using the agent orchestrator.

Expand Down Expand Up @@ -673,22 +674,52 @@ def on_agent_event(event_type: str, data: dict) -> None:
# ReactAgent has a simpler interface — it handles its own
# retries and verification internally.
from codeframe.core.react_agent import ReactAgent
from codeframe.core.stall_detector import StallAction, StallDetectedError

react_agent = ReactAgent(
workspace=workspace,
llm_provider=provider,
stall_timeout_s=stall_timeout_s,
event_publisher=event_publisher,
dry_run=dry_run,
verbose=verbose,
on_event=on_agent_event,
debug=debug,
output_logger=output_logger,
fix_coordinator=fix_coordinator,
)
react_status = react_agent.run(run.task_id)
# Wrap AgentStatus enum into AgentState dataclass for compatibility
state = AgentState(status=react_status)
resolved_action = StallAction(stall_action)

def _build_react_agent() -> ReactAgent:
return ReactAgent(
workspace=workspace,
llm_provider=provider,
stall_timeout_s=stall_timeout_s,
stall_action=resolved_action,
event_publisher=event_publisher,
dry_run=dry_run,
verbose=verbose,
on_event=on_agent_event,
debug=debug,
output_logger=output_logger,
fix_coordinator=fix_coordinator,
)

max_stall_retries = 1
for stall_attempt in range(1 + max_stall_retries):
try:
react_agent = _build_react_agent()
react_status = react_agent.run(run.task_id)
# Wrap AgentStatus enum into AgentState dataclass for compatibility
state = AgentState(status=react_status)
break
except StallDetectedError as exc:
run_logger.warning(
LogCategory.AGENT_ACTION,
f"Stall detected (attempt {stall_attempt + 1}): {exc}",
{"elapsed_s": exc.elapsed_s, "iterations": exc.iterations},
)
if stall_attempt >= max_stall_retries:
run_logger.error(
LogCategory.AGENT_ACTION,
"Max stall retries exceeded, failing task",
{},
)
state = AgentState(status=AgentStatus.FAILED)
break
run_logger.info(
LogCategory.AGENT_ACTION,
"Retrying after stall",
{"attempt": stall_attempt + 2},
)
else:
agent = Agent(
workspace=workspace,
Expand Down
17 changes: 17 additions & 0 deletions codeframe/core/stall_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ class StallAction(str, Enum):
FAIL = "fail"


class StallDetectedError(Exception):
"""Raised when a stall is detected with RETRY action.

Propagates up to ``execute_agent()`` so the runtime can re-invoke
the agent with context about why the previous attempt stalled.
"""

def __init__(self, elapsed_s: float, iterations: int, last_tool: str = "") -> None:
self.elapsed_s = elapsed_s
self.iterations = iterations
self.last_tool = last_tool
super().__init__(
f"Agent stalled after {elapsed_s:.0f}s "
f"(iterations={iterations}, last_tool={last_tool!r})"
)


class StallDetector:
"""Tracks elapsed time since the last recorded activity.

Expand Down
Loading
Loading