Skip to content

Commit 787add1

Browse files
authored
feat: differentiate system/agent/human blocker origins visually (#487)
## Summary - Added `BlockerOrigin` enum (`system | agent | human`) to Python Blocker model with DB migration - Exposed `created_by` field in v2 API `BlockerResponse`; external API always defaults to `human` (prevents origin spoofing) - Agent call sites in `react_agent.py`, `agent.py`, and adapters set correct origin at creation time - `BlockerCard` shows origin badge (icon + color per type) and guidance text per origin ## Validation - Review feedback: All addressed (API validation, security concern, fragile row guard, missing test coverage) - Demo: All 5 acceptance criteria verified via Showboat + React tests - Tests: 8 new Python tests + 6 new React tests, all passing; no regressions - CI: All checks green (Backend Unit Tests, Code Quality, Test Summary, claude-review, GitGuardian) - Linting: Clean (ruff) Closes #487
1 parent 3b3d033 commit 787add1

13 files changed

Lines changed: 225 additions & 10 deletions

File tree

codeframe/core/adapters/builtin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ def _try_tactical_recovery(self, state, task_id: str, build_agent):
254254
self._workspace,
255255
task_id=task_id,
256256
question=f"Technical error: {error_msg[:500]}",
257+
created_by="agent",
257258
)
258259
blockers.answer(self._workspace, blocker.id, resolution)
259260

codeframe/core/adapters/verification_wrapper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def _create_escalation_blocker(
205205
workspace=self._workspace,
206206
question=question,
207207
task_id=task_id,
208+
created_by="agent",
208209
)
209210
except Exception:
210211
logger.warning("Failed to create escalation blocker", exc_info=True)
@@ -235,6 +236,7 @@ def _create_exhaustion_blocker(
235236
workspace=self._workspace,
236237
question=question,
237238
task_id=task_id,
239+
created_by="agent",
238240
)
239241
except Exception:
240242
logger.warning("Failed to create exhaustion blocker", exc_info=True)

codeframe/core/agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,7 @@ def _execute_plan(self) -> None:
621621
workspace=self.workspace,
622622
question=f"Agent aborted: {consecutive_verification_failures} consecutive verification failures at step {step.index} ({step.description}). Last error: {error_msg[:500]}",
623623
task_id=self.state.task_id,
624+
created_by="agent",
624625
)
625626
self.state.status = AgentStatus.BLOCKED
626627
self.state.blocker = BlockerInfo(
@@ -1797,6 +1798,7 @@ def _create_blocker_from_failure(
17971798
workspace=self.workspace,
17981799
question=question,
17991800
task_id=self.state.task_id,
1801+
created_by="agent",
18001802
)
18011803

18021804
self.state.status = AgentStatus.BLOCKED
@@ -1888,6 +1890,7 @@ def _create_escalation_blocker(
18881890
workspace=self.workspace,
18891891
question=question,
18901892
task_id=self.state.task_id,
1893+
created_by="agent",
18911894
)
18921895

18931896
self.state.status = AgentStatus.BLOCKED

codeframe/core/blockers.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ class BlockerStatus(str, Enum):
3030
RESOLVED = "RESOLVED"
3131

3232

33+
class BlockerOrigin(str, Enum):
34+
"""Who created the blocker."""
35+
36+
SYSTEM = "system"
37+
AGENT = "agent"
38+
HUMAN = "human"
39+
40+
3341
@dataclass
3442
class Blocker:
3543
"""Represents a blocker (human-in-the-loop question).
@@ -43,6 +51,7 @@ class Blocker:
4351
status: Current blocker status
4452
created_at: When the blocker was created
4553
answered_at: When the blocker was answered (if answered)
54+
created_by: Origin of the blocker (system, agent, or human)
4655
"""
4756

4857
id: str
@@ -53,23 +62,27 @@ class Blocker:
5362
status: BlockerStatus
5463
created_at: datetime
5564
answered_at: Optional[datetime]
65+
created_by: BlockerOrigin = BlockerOrigin.HUMAN
5666

5767

5868
def create(
5969
workspace: Workspace,
6070
question: str,
6171
task_id: Optional[str] = None,
72+
created_by: str = "human",
6273
) -> Blocker:
6374
"""Create a new blocker.
6475
6576
Args:
6677
workspace: Target workspace
6778
question: The question to ask
6879
task_id: Optional associated task ID
80+
created_by: Origin of the blocker ("system", "agent", or "human")
6981
7082
Returns:
7183
Created Blocker
7284
"""
85+
origin = BlockerOrigin(created_by)
7386
blocker_id = str(uuid.uuid4())
7487
now = _utc_now().isoformat()
7588

@@ -78,10 +91,10 @@ def create(
7891

7992
cursor.execute(
8093
"""
81-
INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at)
82-
VALUES (?, ?, ?, ?, ?, ?)
94+
INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at, created_by)
95+
VALUES (?, ?, ?, ?, ?, ?, ?)
8396
""",
84-
(blocker_id, workspace.id, task_id, question, BlockerStatus.OPEN.value, now),
97+
(blocker_id, workspace.id, task_id, question, BlockerStatus.OPEN.value, now, origin.value),
8598
)
8699
conn.commit()
87100
conn.close()
@@ -95,6 +108,7 @@ def create(
95108
status=BlockerStatus.OPEN,
96109
created_at=datetime.fromisoformat(now),
97110
answered_at=None,
111+
created_by=origin,
98112
)
99113

100114
# Emit blocker created event
@@ -126,7 +140,8 @@ def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]:
126140
# Try exact match first
127141
cursor.execute(
128142
"""
129-
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at
143+
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
144+
COALESCE(created_by, 'human') as created_by
130145
FROM blockers
131146
WHERE workspace_id = ? AND id = ?
132147
""",
@@ -138,7 +153,8 @@ def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]:
138153
if not row:
139154
cursor.execute(
140155
"""
141-
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at
156+
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
157+
COALESCE(created_by, 'human') as created_by
142158
FROM blockers
143159
WHERE workspace_id = ? AND id LIKE ?
144160
""",
@@ -192,7 +208,8 @@ def list_all(
192208
cursor = conn.cursor()
193209

194210
query = """
195-
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at
211+
SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
212+
COALESCE(created_by, 'human') as created_by
196213
FROM blockers
197214
WHERE workspace_id = ?
198215
"""
@@ -383,4 +400,5 @@ def _row_to_blocker(row: tuple) -> Blocker:
383400
status=BlockerStatus(row[5]),
384401
created_at=datetime.fromisoformat(row[6]),
385402
answered_at=datetime.fromisoformat(row[7]) if row[7] else None,
403+
created_by=BlockerOrigin(row[8]),
386404
)

codeframe/core/prd_discovery.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ def pause_discovery(self, reason: str) -> str:
547547
)
548548

549549
blocker = blockers.create(
550-
self.workspace, question=question, task_id=None
550+
self.workspace, question=question, task_id=None, created_by="system"
551551
)
552552
self._blocker_id = blocker.id
553553
self._save_session()

codeframe/core/react_agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,10 +1209,12 @@ def _create_text_blocker(self, text: str, reason: str) -> None:
12091209
f"Agent detected a blocker: {reason}\n\n"
12101210
f"Context:\n{text[:500]}"
12111211
)
1212+
origin = "system" if reason == "stall_detected" else "agent"
12121213
blocker = blockers.create(
12131214
workspace=self.workspace,
12141215
question=question,
12151216
task_id=self._current_task_id,
1217+
created_by=origin,
12161218
)
12171219
self.blocker_id = blocker.id
12181220

@@ -1232,6 +1234,7 @@ def _create_escalation_blocker(
12321234
workspace=self.workspace,
12331235
question=question,
12341236
task_id=self._current_task_id,
1237+
created_by="agent",
12351238
)
12361239
self.blocker_id = blocker.id
12371240

codeframe/core/workspace.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,20 @@ def _init_database(db_path: Path) -> None:
173173
status TEXT NOT NULL DEFAULT 'OPEN',
174174
created_at TEXT NOT NULL,
175175
answered_at TEXT,
176+
created_by TEXT NOT NULL DEFAULT 'human',
176177
FOREIGN KEY (workspace_id) REFERENCES workspace(id),
177178
FOREIGN KEY (task_id) REFERENCES tasks(id),
178-
CHECK (status IN ('OPEN', 'ANSWERED', 'RESOLVED'))
179+
CHECK (status IN ('OPEN', 'ANSWERED', 'RESOLVED')),
180+
CHECK (created_by IN ('system', 'agent', 'human'))
179181
)
180182
""")
181183

184+
# Migration: Add created_by column to existing blockers table
185+
cursor.execute("PRAGMA table_info(blockers)")
186+
blocker_columns = {row[1] for row in cursor.fetchall()}
187+
if "created_by" not in blocker_columns:
188+
cursor.execute("ALTER TABLE blockers ADD COLUMN created_by TEXT NOT NULL DEFAULT 'human'")
189+
182190
# Checkpoints (state snapshots)
183191
cursor.execute("""
184192
CREATE TABLE IF NOT EXISTS checkpoints (

codeframe/ui/routers/blockers_v2.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class BlockerResponse(BaseModel):
4545
status: str
4646
created_at: str
4747
answered_at: Optional[str]
48+
created_by: str = "human"
4849

4950

5051
class BlockerListResponse(BaseModel):
@@ -84,6 +85,7 @@ def _blocker_to_response(blocker: blockers.Blocker) -> BlockerResponse:
8485
status=blocker.status.value,
8586
created_at=blocker.created_at.isoformat(),
8687
answered_at=blocker.answered_at.isoformat() if blocker.answered_at else None,
88+
created_by=blocker.created_by.value,
8789
)
8890

8991

tests/core/test_blocker_origin.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Tests for blocker origin (created_by) field — issue #487."""
2+
3+
import pytest
4+
from pathlib import Path
5+
6+
from codeframe.core.workspace import create_or_load_workspace
7+
from codeframe.core import blockers
8+
from codeframe.core.blockers import BlockerOrigin
9+
10+
pytestmark = pytest.mark.v2
11+
12+
13+
@pytest.fixture
14+
def workspace(tmp_path: Path):
15+
repo = tmp_path / "repo"
16+
repo.mkdir()
17+
return create_or_load_workspace(repo)
18+
19+
20+
class TestBlockerOriginEnum:
21+
def test_valid_values(self):
22+
assert BlockerOrigin.SYSTEM == "system"
23+
assert BlockerOrigin.AGENT == "agent"
24+
assert BlockerOrigin.HUMAN == "human"
25+
26+
27+
class TestBlockerCreate:
28+
def test_invalid_origin_raises_value_error(self, workspace):
29+
with pytest.raises(ValueError):
30+
blockers.create(workspace, "A question?", created_by="invalid")
31+
32+
def test_default_origin_is_human(self, workspace):
33+
b = blockers.create(workspace, "A question?")
34+
assert b.created_by == BlockerOrigin.HUMAN
35+
36+
def test_agent_origin(self, workspace):
37+
b = blockers.create(workspace, "Agent question?", created_by="agent")
38+
assert b.created_by == BlockerOrigin.AGENT
39+
40+
def test_system_origin(self, workspace):
41+
b = blockers.create(workspace, "Stall detected", created_by="system")
42+
assert b.created_by == BlockerOrigin.SYSTEM
43+
44+
def test_origin_persisted_and_retrieved(self, workspace):
45+
created = blockers.create(workspace, "Test?", created_by="agent")
46+
fetched = blockers.get(workspace, created.id)
47+
assert fetched is not None
48+
assert fetched.created_by == BlockerOrigin.AGENT
49+
50+
51+
class TestBlockerListIncludesOrigin:
52+
def test_list_all_includes_created_by(self, workspace):
53+
blockers.create(workspace, "Q1", created_by="human")
54+
blockers.create(workspace, "Q2", created_by="agent")
55+
result = blockers.list_all(workspace)
56+
assert len(result) == 2
57+
origins = {b.created_by for b in result}
58+
assert BlockerOrigin.HUMAN in origins
59+
assert BlockerOrigin.AGENT in origins
60+
61+
62+
class TestExistingBlockersMigration:
63+
def test_blockers_without_created_by_default_to_human(self, workspace):
64+
"""Simulate a pre-migration blocker row with no created_by value."""
65+
from codeframe.core.workspace import get_db_connection
66+
import uuid
67+
from datetime import datetime, timezone
68+
69+
conn = get_db_connection(workspace)
70+
old_id = str(uuid.uuid4())
71+
now = datetime.now(timezone.utc).isoformat()
72+
conn.execute(
73+
"""
74+
INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at)
75+
VALUES (?, ?, NULL, ?, 'OPEN', ?)
76+
""",
77+
(old_id, workspace.id, "Old question?", now),
78+
)
79+
conn.commit()
80+
conn.close()
81+
82+
fetched = blockers.get(workspace, old_id)
83+
assert fetched is not None
84+
assert fetched.created_by == BlockerOrigin.HUMAN

web-ui/__mocks__/@hugeicons/react.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,7 @@ module.exports = {
6565
// FileTreePanel / DiffViewer
6666
FileAddIcon: createIconMock('FileAddIcon'),
6767
FileRemoveIcon: createIconMock('FileRemoveIcon'),
68+
// BlockerCard origin badges
69+
Settings01Icon: createIconMock('Settings01Icon'),
70+
UserCircle02Icon: createIconMock('UserCircle02Icon'),
6871
};

0 commit comments

Comments
 (0)