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
1 change: 1 addition & 0 deletions codeframe/core/adapters/builtin.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def _try_tactical_recovery(self, state, task_id: str, build_agent):
self._workspace,
task_id=task_id,
question=f"Technical error: {error_msg[:500]}",
created_by="agent",
)
blockers.answer(self._workspace, blocker.id, resolution)

Expand Down
2 changes: 2 additions & 0 deletions codeframe/core/adapters/verification_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def _create_escalation_blocker(
workspace=self._workspace,
question=question,
task_id=task_id,
created_by="agent",
)
except Exception:
logger.warning("Failed to create escalation blocker", exc_info=True)
Expand Down Expand Up @@ -235,6 +236,7 @@ def _create_exhaustion_blocker(
workspace=self._workspace,
question=question,
task_id=task_id,
created_by="agent",
)
except Exception:
logger.warning("Failed to create exhaustion blocker", exc_info=True)
Expand Down
3 changes: 3 additions & 0 deletions codeframe/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ def _execute_plan(self) -> None:
workspace=self.workspace,
question=f"Agent aborted: {consecutive_verification_failures} consecutive verification failures at step {step.index} ({step.description}). Last error: {error_msg[:500]}",
task_id=self.state.task_id,
created_by="agent",
)
self.state.status = AgentStatus.BLOCKED
self.state.blocker = BlockerInfo(
Expand Down Expand Up @@ -1797,6 +1798,7 @@ def _create_blocker_from_failure(
workspace=self.workspace,
question=question,
task_id=self.state.task_id,
created_by="agent",
)

self.state.status = AgentStatus.BLOCKED
Expand Down Expand Up @@ -1888,6 +1890,7 @@ def _create_escalation_blocker(
workspace=self.workspace,
question=question,
task_id=self.state.task_id,
created_by="agent",
)

self.state.status = AgentStatus.BLOCKED
Expand Down
30 changes: 24 additions & 6 deletions codeframe/core/blockers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ class BlockerStatus(str, Enum):
RESOLVED = "RESOLVED"


class BlockerOrigin(str, Enum):
"""Who created the blocker."""

SYSTEM = "system"
AGENT = "agent"
HUMAN = "human"


@dataclass
class Blocker:
"""Represents a blocker (human-in-the-loop question).
Expand All @@ -43,6 +51,7 @@ class Blocker:
status: Current blocker status
created_at: When the blocker was created
answered_at: When the blocker was answered (if answered)
created_by: Origin of the blocker (system, agent, or human)
"""

id: str
Expand All @@ -53,23 +62,27 @@ class Blocker:
status: BlockerStatus
created_at: datetime
answered_at: Optional[datetime]
created_by: BlockerOrigin = BlockerOrigin.HUMAN


def create(
workspace: Workspace,
question: str,
task_id: Optional[str] = None,
created_by: str = "human",
) -> Blocker:
"""Create a new blocker.

Args:
workspace: Target workspace
question: The question to ask
task_id: Optional associated task ID
created_by: Origin of the blocker ("system", "agent", or "human")

Returns:
Created Blocker
"""
origin = BlockerOrigin(created_by)
blocker_id = str(uuid.uuid4())
now = _utc_now().isoformat()

Expand All @@ -78,10 +91,10 @@ def create(

cursor.execute(
"""
INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(blocker_id, workspace.id, task_id, question, BlockerStatus.OPEN.value, now),
(blocker_id, workspace.id, task_id, question, BlockerStatus.OPEN.value, now, origin.value),
)
conn.commit()
conn.close()
Expand All @@ -95,6 +108,7 @@ def create(
status=BlockerStatus.OPEN,
created_at=datetime.fromisoformat(now),
answered_at=None,
created_by=origin,
)

# Emit blocker created event
Expand Down Expand Up @@ -126,7 +140,8 @@ def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]:
# Try exact match first
cursor.execute(
"""
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
COALESCE(created_by, 'human') as created_by
FROM blockers
WHERE workspace_id = ? AND id = ?
""",
Expand All @@ -138,7 +153,8 @@ def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]:
if not row:
cursor.execute(
"""
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
COALESCE(created_by, 'human') as created_by
FROM blockers
WHERE workspace_id = ? AND id LIKE ?
""",
Expand Down Expand Up @@ -192,7 +208,8 @@ def list_all(
cursor = conn.cursor()

query = """
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
COALESCE(created_by, 'human') as created_by
FROM blockers
WHERE workspace_id = ?
"""
Expand Down Expand Up @@ -383,4 +400,5 @@ def _row_to_blocker(row: tuple) -> Blocker:
status=BlockerStatus(row[5]),
created_at=datetime.fromisoformat(row[6]),
answered_at=datetime.fromisoformat(row[7]) if row[7] else None,
created_by=BlockerOrigin(row[8]),
)
2 changes: 1 addition & 1 deletion codeframe/core/prd_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ def pause_discovery(self, reason: str) -> str:
)

blocker = blockers.create(
self.workspace, question=question, task_id=None
self.workspace, question=question, task_id=None, created_by="system"
)
self._blocker_id = blocker.id
self._save_session()
Expand Down
3 changes: 3 additions & 0 deletions codeframe/core/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1209,10 +1209,12 @@ 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"
blocker = blockers.create(
workspace=self.workspace,
question=question,
task_id=self._current_task_id,
created_by=origin,
)
self.blocker_id = blocker.id

Expand All @@ -1232,6 +1234,7 @@ def _create_escalation_blocker(
workspace=self.workspace,
question=question,
task_id=self._current_task_id,
created_by="agent",
)
self.blocker_id = blocker.id

Expand Down
10 changes: 9 additions & 1 deletion codeframe/core/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,20 @@ def _init_database(db_path: Path) -> None:
status TEXT NOT NULL DEFAULT 'OPEN',
created_at TEXT NOT NULL,
answered_at TEXT,
created_by TEXT NOT NULL DEFAULT 'human',
FOREIGN KEY (workspace_id) REFERENCES workspace(id),
FOREIGN KEY (task_id) REFERENCES tasks(id),
CHECK (status IN ('OPEN', 'ANSWERED', 'RESOLVED'))
CHECK (status IN ('OPEN', 'ANSWERED', 'RESOLVED')),
CHECK (created_by IN ('system', 'agent', 'human'))
)
""")

# Migration: Add created_by column to existing blockers table
cursor.execute("PRAGMA table_info(blockers)")
blocker_columns = {row[1] for row in cursor.fetchall()}
if "created_by" not in blocker_columns:
cursor.execute("ALTER TABLE blockers ADD COLUMN created_by TEXT NOT NULL DEFAULT 'human'")

# Checkpoints (state snapshots)
cursor.execute("""
CREATE TABLE IF NOT EXISTS checkpoints (
Expand Down
2 changes: 2 additions & 0 deletions codeframe/ui/routers/blockers_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class BlockerResponse(BaseModel):
status: str
created_at: str
answered_at: Optional[str]
created_by: str = "human"


class BlockerListResponse(BaseModel):
Expand Down Expand Up @@ -84,6 +85,7 @@ def _blocker_to_response(blocker: blockers.Blocker) -> BlockerResponse:
status=blocker.status.value,
created_at=blocker.created_at.isoformat(),
answered_at=blocker.answered_at.isoformat() if blocker.answered_at else None,
created_by=blocker.created_by.value,
)


Expand Down
84 changes: 84 additions & 0 deletions tests/core/test_blocker_origin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Tests for blocker origin (created_by) field — issue #487."""

import pytest
from pathlib import Path

from codeframe.core.workspace import create_or_load_workspace
from codeframe.core import blockers
from codeframe.core.blockers import BlockerOrigin

pytestmark = pytest.mark.v2


@pytest.fixture
def workspace(tmp_path: Path):
repo = tmp_path / "repo"
repo.mkdir()
return create_or_load_workspace(repo)


class TestBlockerOriginEnum:
def test_valid_values(self):
assert BlockerOrigin.SYSTEM == "system"
assert BlockerOrigin.AGENT == "agent"
assert BlockerOrigin.HUMAN == "human"


class TestBlockerCreate:
def test_invalid_origin_raises_value_error(self, workspace):
with pytest.raises(ValueError):
blockers.create(workspace, "A question?", created_by="invalid")

def test_default_origin_is_human(self, workspace):
b = blockers.create(workspace, "A question?")
assert b.created_by == BlockerOrigin.HUMAN

def test_agent_origin(self, workspace):
b = blockers.create(workspace, "Agent question?", created_by="agent")
assert b.created_by == BlockerOrigin.AGENT

def test_system_origin(self, workspace):
b = blockers.create(workspace, "Stall detected", created_by="system")
assert b.created_by == BlockerOrigin.SYSTEM

def test_origin_persisted_and_retrieved(self, workspace):
created = blockers.create(workspace, "Test?", created_by="agent")
fetched = blockers.get(workspace, created.id)
assert fetched is not None
assert fetched.created_by == BlockerOrigin.AGENT


class TestBlockerListIncludesOrigin:
def test_list_all_includes_created_by(self, workspace):
blockers.create(workspace, "Q1", created_by="human")
blockers.create(workspace, "Q2", created_by="agent")
result = blockers.list_all(workspace)
assert len(result) == 2
origins = {b.created_by for b in result}
assert BlockerOrigin.HUMAN in origins
assert BlockerOrigin.AGENT in origins


class TestExistingBlockersMigration:
def test_blockers_without_created_by_default_to_human(self, workspace):
"""Simulate a pre-migration blocker row with no created_by value."""
from codeframe.core.workspace import get_db_connection
import uuid
from datetime import datetime, timezone

conn = get_db_connection(workspace)
old_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"""
INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at)
VALUES (?, ?, NULL, ?, 'OPEN', ?)
""",
(old_id, workspace.id, "Old question?", now),
)
conn.commit()
conn.close()

fetched = blockers.get(workspace, old_id)
assert fetched is not None
assert fetched.created_by == BlockerOrigin.HUMAN
3 changes: 3 additions & 0 deletions web-ui/__mocks__/@hugeicons/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,7 @@ module.exports = {
// FileTreePanel / DiffViewer
FileAddIcon: createIconMock('FileAddIcon'),
FileRemoveIcon: createIconMock('FileRemoveIcon'),
// BlockerCard origin badges
Settings01Icon: createIconMock('Settings01Icon'),
UserCircle02Icon: createIconMock('UserCircle02Icon'),
};
45 changes: 45 additions & 0 deletions web-ui/__tests__/components/blockers/BlockerCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function makeBlocker(overrides: Partial<Blocker> = {}): Blocker {
status: 'OPEN',
created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(), // 30m ago
answered_at: null,
created_by: 'human',
...overrides,
};
}
Expand Down Expand Up @@ -217,4 +218,48 @@ describe('BlockerCard', () => {

expect(screen.getByLabelText('Your answer to the blocker question')).toBeInTheDocument();
});

describe('origin badge', () => {
it('shows Manual badge for human-created blocker', () => {
render(
<BlockerCard blocker={makeBlocker({ created_by: 'human' })} workspacePath={workspacePath} onAnswered={onAnswered} />
);
expect(screen.getByTestId('origin-badge')).toHaveTextContent('Manual');
});

it('shows Agent badge for agent-created blocker', () => {
render(
<BlockerCard blocker={makeBlocker({ created_by: 'agent' })} workspacePath={workspacePath} onAnswered={onAnswered} />
);
expect(screen.getByTestId('origin-badge')).toHaveTextContent('Agent');
});

it('shows System badge for system-created blocker', () => {
render(
<BlockerCard blocker={makeBlocker({ created_by: 'system' })} workspacePath={workspacePath} onAnswered={onAnswered} />
);
expect(screen.getByTestId('origin-badge')).toHaveTextContent('System');
});

it('shows correct guidance for human blocker', () => {
render(
<BlockerCard blocker={makeBlocker({ created_by: 'human' })} workspacePath={workspacePath} onAnswered={onAnswered} />
);
expect(screen.getByText('Manually created blocker. Resolve and mark answered.')).toBeInTheDocument();
});

it('shows correct guidance for agent blocker', () => {
render(
<BlockerCard blocker={makeBlocker({ created_by: 'agent' })} workspacePath={workspacePath} onAnswered={onAnswered} />
);
expect(screen.getByText('Agent requested information. Provide the answer below.')).toBeInTheDocument();
});

it('shows correct guidance for system blocker', () => {
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();
});
});
});
Loading
Loading