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
4 changes: 2 additions & 2 deletions codeframe/core/blockers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from typing import Optional, Union

from codeframe.core.workspace import Workspace, get_db_connection
from codeframe.core import events, runtime, tasks
Expand Down Expand Up @@ -69,7 +69,7 @@ def create(
workspace: Workspace,
question: str,
task_id: Optional[str] = None,
created_by: str = "human",
created_by: Union[BlockerOrigin, str] = BlockerOrigin.HUMAN,
) -> Blocker:
"""Create a new blocker.

Expand Down
14 changes: 9 additions & 5 deletions codeframe/core/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
PRESERVE_RECENT_PAIRS = 5
DEFAULT_CONTEXT_WINDOW = 200_000 # All Claude 4.x models

# Reason string emitted when a stall timeout triggers a blocker — used to
# set the correct BlockerOrigin ("system") vs agent-generated blockers.
_REASON_STALL_DETECTED = "stall_detected"

# Map tool names to agent phases for progress reporting.
_TOOL_PHASE_MAP = {
"read_file": AgentPhase.EXPLORING,
Expand Down Expand Up @@ -220,7 +224,7 @@ 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"
reason = _REASON_STALL_DETECTED if self._stall_triggered.is_set() else "max_iterations_reached"
self._emit(EventType.AGENT_FAILED, {
"task_id": task_id,
"reason": reason,
Expand All @@ -244,7 +248,7 @@ def run(self, task_id: str) -> AgentStatus:
self._emit_stream_completion(task_id)
return AgentStatus.COMPLETED

if reason == "escalated_to_blocker" or reason == "stall_detected":
if reason == "escalated_to_blocker" or reason == _REASON_STALL_DETECTED:
self._emit(EventType.AGENT_FAILED, {
"task_id": task_id,
"reason": "blocked",
Expand Down Expand Up @@ -433,7 +437,7 @@ def _react_loop(self, system_prompt: str) -> AgentStatus:
# StallAction.BLOCKER (default)
self._create_text_blocker(
stall_ctx or "Agent stalled with no tool activity",
"stall_detected",
_REASON_STALL_DETECTED,
)
return AgentStatus.BLOCKED

Expand Down Expand Up @@ -689,7 +693,7 @@ def _run_final_verification(
)
elif self._stall_action == StallAction.FAIL:
return (False, "stall_failed")
return (False, "stall_detected")
return (False, _REASON_STALL_DETECTED)

self._verbose_print("[ReactAgent] Running final verification...")
self._emit_progress(
Expand Down Expand Up @@ -1209,7 +1213,7 @@ def _create_text_blocker(self, text: str, reason: str) -> None:
f"Agent detected a blocker: {reason}\n\n"
f"Context:\n{text[:500]}"
)
origin = "system" if reason == "stall_detected" else "agent"
origin = "system" if reason == _REASON_STALL_DETECTED else "agent"
blocker = blockers.create(
workspace=self.workspace,
question=question,
Expand Down
4 changes: 2 additions & 2 deletions codeframe/ui/routers/blockers_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from codeframe.core.workspace import Workspace
from codeframe.lib.rate_limiter import rate_limit_standard
from codeframe.core import blockers
from codeframe.core.blockers import BlockerStatus
from codeframe.core.blockers import BlockerStatus, BlockerOrigin
from codeframe.ui.dependencies import get_v2_workspace
from codeframe.ui.response_models import api_error, ErrorCodes

Expand All @@ -45,7 +45,7 @@ class BlockerResponse(BaseModel):
status: str
created_at: str
answered_at: Optional[str]
created_by: str = "human"
created_by: BlockerOrigin = BlockerOrigin.HUMAN


class BlockerListResponse(BaseModel):
Expand Down
47 changes: 46 additions & 1 deletion tests/core/test_blocker_origin.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def test_list_all_includes_created_by(self, workspace):

class TestExistingBlockersMigration:
def test_blockers_without_created_by_default_to_human(self, workspace):
"""Simulate a pre-migration blocker row with no created_by value."""
"""COALESCE fallback: rows inserted without created_by read back as HUMAN."""
from codeframe.core.workspace import get_db_connection
import uuid
from datetime import datetime, timezone
Expand All @@ -82,3 +82,48 @@ def test_blockers_without_created_by_default_to_human(self, workspace):
fetched = blockers.get(workspace, old_id)
assert fetched is not None
assert fetched.created_by == BlockerOrigin.HUMAN

def test_alter_table_migration_adds_created_by_column(self, tmp_path: Path):
"""ALTER TABLE migration: initializing a DB without created_by column adds it."""
import sqlite3
from codeframe.core.workspace import _init_database, CODEFRAME_DIR, STATE_DB_NAME

# Create a DB with the old blockers schema (no created_by column)
repo = tmp_path / "old-repo"
repo.mkdir()
codeframe_dir = repo / CODEFRAME_DIR
codeframe_dir.mkdir()
db_path = codeframe_dir / STATE_DB_NAME

conn = sqlite3.connect(str(db_path))
conn.execute("""
CREATE TABLE IF NOT EXISTS blockers (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
task_id TEXT,
question TEXT NOT NULL,
answer TEXT,
status TEXT NOT NULL DEFAULT 'OPEN',
created_at TEXT NOT NULL,
answered_at TEXT
)
""")
conn.commit()
conn.close()

# Confirm column is absent before migration
conn = sqlite3.connect(str(db_path))
cursor = conn.execute("PRAGMA table_info(blockers)")
columns_before = {row[1] for row in cursor.fetchall()}
conn.close()
assert "created_by" not in columns_before

# Run the full init (triggers the ALTER TABLE migration)
_init_database(db_path)

# Confirm column is present after migration
conn = sqlite3.connect(str(db_path))
cursor = conn.execute("PRAGMA table_info(blockers)")
columns_after = {row[1] for row in cursor.fetchall()}
conn.close()
assert "created_by" in columns_after
2 changes: 1 addition & 1 deletion web-ui/__tests__/components/blockers/BlockerCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ describe('BlockerCard', () => {
render(
<BlockerCard blocker={makeBlocker({ created_by: 'system' })} workspacePath={workspacePath} onAnswered={onAnswered} />
);
expect(screen.getByText('Agent was inactive for too long. Answer to continue or retry execution.')).toBeInTheDocument();
expect(screen.getByText('System-initiated pause. Review the context and answer to continue.')).toBeInTheDocument();
});
});
});
2 changes: 1 addition & 1 deletion web-ui/src/components/blockers/BlockerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const ORIGIN_CONFIG: Record<BlockerOrigin, {
label: 'System',
Icon: Settings01Icon,
badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-950/40 dark:text-blue-300',
guidance: 'Agent was inactive for too long. Answer to continue or retry execution.',
guidance: 'System-initiated pause. Review the context and answer to continue.',
},
agent: {
label: 'Agent',
Expand Down
Loading