Skip to content

Commit 5d13d29

Browse files
frankbriaTest User
andauthored
fix(tasks): add Assign Tasks button for stuck pending tasks (#250)
* fix(tasks): add Assign Tasks button for stuck pending tasks (#248) Fixes Issue #248 where tasks remained stuck in "pending" state with "Assigned to: Unassigned" for late-joining users or after execution failures. Changes: - Fix LeadAgent._assign_and_execute_task() to set assigned_to field before status transitions (root cause of display bug) - Add POST /api/projects/{id}/tasks/assign endpoint for manual trigger - Add tasksApi.assignPending() frontend API client method - Add "Assign Tasks" banner to TaskList when pending unassigned tasks exist - Add comprehensive tests for all new functionality The button appears contextually only when tasks are stuck, and auto-hides when tasks get assigned via WebSocket updates. * fix(tasks): prevent concurrent execution and improve button UX Phase 1 fix for concurrent execution issue: - Backend: Check for in_progress tasks before scheduling new execution - Frontend: Disable button when tasks are already running - Frontend: Show "Running..." state and updated message when execution active - Add 2 new tests for execution blocking behavior * chore: remove unused Task imports in tests * fix(tasks): address code review feedback - Return success=False when API key missing (blocking issue) - Add ASSIGNED status check to prevent duplicate triggers - Add debug logging for zero pending tasks case - Add test for ASSIGNED status blocking --------- Co-authored-by: Test User <[email protected]>
1 parent ddd386f commit 5d13d29

File tree

6 files changed

+815
-1
lines changed

6 files changed

+815
-1
lines changed

codeframe/agents/lead_agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1870,7 +1870,9 @@ async def _assign_and_execute_task(self, task: Task, retry_counts: Dict[int, int
18701870
# Mark agent busy
18711871
self.agent_pool_manager.mark_agent_busy(agent_id, task.id)
18721872

1873-
# Update task status to in_progress
1873+
# Update task with assigned agent and status (Issue #248 fix)
1874+
# Set assigned_to BEFORE status change so UI shows assignment immediately
1875+
self.db.update_task(task.id, {"assigned_to": agent_id})
18741876
self.db.update_task(task.id, {"status": "in_progress"})
18751877

18761878
# Get agent instance

codeframe/ui/routers/tasks.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,131 @@ async def approve_tasks(
370370
excluded_count=len(excluded_tasks),
371371
message=f"Successfully approved {len(approved_tasks)} tasks. Development phase started."
372372
)
373+
374+
375+
# ============================================================================
376+
# Task Assignment Endpoint (Issue #248 - Manual trigger for stuck tasks)
377+
# ============================================================================
378+
379+
380+
class TaskAssignmentResponse(BaseModel):
381+
"""Response model for task assignment."""
382+
success: bool
383+
pending_count: int
384+
message: str
385+
386+
387+
@project_router.post("/{project_id}/tasks/assign")
388+
async def assign_pending_tasks(
389+
project_id: int,
390+
background_tasks: BackgroundTasks,
391+
db: Database = Depends(get_db),
392+
current_user: User = Depends(get_current_user),
393+
) -> TaskAssignmentResponse:
394+
"""Manually trigger task assignment for pending unassigned tasks.
395+
396+
This endpoint allows users to restart the multi-agent execution process
397+
when tasks are stuck in 'pending' state with no agent assigned. This can
398+
happen when:
399+
- User joins a session after the initial execution completed/failed
400+
- The original execution timed out or crashed
401+
- WebSocket messages were missed
402+
403+
Args:
404+
project_id: Project ID
405+
background_tasks: FastAPI background tasks for async execution
406+
db: Database connection
407+
current_user: Authenticated user
408+
409+
Returns:
410+
TaskAssignmentResponse with pending task count and status
411+
412+
Raises:
413+
HTTPException:
414+
- 400: Project not in active phase
415+
- 403: Access denied
416+
- 404: Project not found
417+
"""
418+
# Verify project exists
419+
project = db.get_project(project_id)
420+
if not project:
421+
raise HTTPException(
422+
status_code=404,
423+
detail=f"Project {project_id} not found"
424+
)
425+
426+
# Authorization check
427+
if not db.user_has_project_access(current_user.id, project_id):
428+
raise HTTPException(status_code=403, detail="Access denied")
429+
430+
# Validate project is in active phase (development)
431+
current_phase = project.get("phase", "discovery")
432+
if current_phase != "active":
433+
raise HTTPException(
434+
status_code=400,
435+
detail=f"Project must be in active (development) phase to assign tasks. Current phase: {current_phase}"
436+
)
437+
438+
# Get all tasks and count pending unassigned ones
439+
tasks = db.get_project_tasks(project_id)
440+
pending_unassigned = [
441+
t for t in tasks
442+
if t.status == TaskStatus.PENDING and not t.assigned_to
443+
]
444+
pending_count = len(pending_unassigned)
445+
446+
if pending_count == 0:
447+
# Debug logging to help diagnose why tasks might appear stuck
448+
logger.debug(
449+
f"assign_pending_tasks called for project {project_id} but found 0 pending unassigned tasks. "
450+
f"Total tasks: {len(tasks)}, statuses: {[t.status.value for t in tasks]}"
451+
)
452+
return TaskAssignmentResponse(
453+
success=True,
454+
pending_count=0,
455+
message="No pending unassigned tasks to assign."
456+
)
457+
458+
# Check if execution is already in progress (Phase 1 fix for concurrent execution)
459+
# Include ASSIGNED status to prevent race between assignment and execution start
460+
executing_tasks = [
461+
t for t in tasks
462+
if t.status in [TaskStatus.ASSIGNED, TaskStatus.IN_PROGRESS]
463+
]
464+
if executing_tasks:
465+
logger.info(
466+
f"⏳ Execution already in progress for project {project_id}: "
467+
f"{len(executing_tasks)} tasks assigned/running"
468+
)
469+
return TaskAssignmentResponse(
470+
success=True,
471+
pending_count=pending_count,
472+
message=f"Execution already in progress ({len(executing_tasks)} task(s) assigned/running). Please wait."
473+
)
474+
475+
# Schedule multi-agent execution in background
476+
api_key = os.environ.get("ANTHROPIC_API_KEY")
477+
if not api_key:
478+
logger.warning(
479+
f"⚠️ ANTHROPIC_API_KEY not configured - cannot assign tasks for project {project_id}"
480+
)
481+
return TaskAssignmentResponse(
482+
success=False,
483+
pending_count=pending_count,
484+
message="Cannot assign tasks: API key not configured. Please contact administrator."
485+
)
486+
487+
background_tasks.add_task(
488+
start_development_execution,
489+
project_id,
490+
db,
491+
manager,
492+
api_key
493+
)
494+
logger.info(f"✅ Scheduled task assignment for project {project_id} ({pending_count} pending tasks)")
495+
496+
return TaskAssignmentResponse(
497+
success=True,
498+
pending_count=pending_count,
499+
message=f"Assignment started for {pending_count} pending task(s)."
500+
)

tests/integration/test_multi_agent_execution.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,3 +667,229 @@ def record_usage(i: int):
667667
total = cursor.fetchone()["total"]
668668
expected_total = sum(100 + i for i in range(20))
669669
assert total == expected_total
670+
671+
672+
@pytest.mark.integration
673+
class TestAssignAndExecuteTaskAssignedTo:
674+
"""Tests for _assign_and_execute_task setting assigned_to field (Issue #248 fix)."""
675+
676+
@pytest.mark.asyncio
677+
async def test_assign_and_execute_task_sets_assigned_to(
678+
self, real_db: Database, test_workspace: Path
679+
):
680+
"""Test that _assign_and_execute_task sets the assigned_to field on the task.
681+
682+
This is a regression test for Issue #248 where tasks remained showing
683+
'Assigned to: Unassigned' because the assigned_to field was never populated
684+
during task execution.
685+
"""
686+
from codeframe.agents.lead_agent import LeadAgent
687+
688+
# Setup project
689+
project_id = real_db.create_project(
690+
name="assigned-to-test",
691+
description="Test assigned_to field population",
692+
source_type="empty",
693+
workspace_path=str(test_workspace),
694+
)
695+
696+
# Create issue and task
697+
issue_id = real_db.create_issue({
698+
"project_id": project_id,
699+
"issue_number": "AT-001",
700+
"title": "Test Issue",
701+
"description": "Test issue for assigned_to",
702+
"priority": 1,
703+
"workflow_step": 1,
704+
})
705+
706+
task_id = real_db.create_task_with_issue(
707+
project_id=project_id,
708+
issue_id=issue_id,
709+
task_number="AT-001-1",
710+
parent_issue_number="AT-001",
711+
title="Test Task for Assignment",
712+
description="This task should have assigned_to set",
713+
status=TaskStatus.PENDING,
714+
priority=1,
715+
workflow_step=1,
716+
can_parallelize=True,
717+
)
718+
719+
# Verify task starts with no assigned_to
720+
task_before = real_db.get_task(task_id)
721+
assert task_before.assigned_to is None, "Task should start unassigned"
722+
723+
# Create LeadAgent with mocked execution
724+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test-key"}):
725+
with patch("codeframe.agents.lead_agent.AgentPoolManager") as mock_pool_class:
726+
# Setup mock pool manager
727+
mock_pool = Mock()
728+
mock_pool.get_or_create_agent.return_value = "test-agent-001"
729+
mock_pool.mark_agent_busy.return_value = None
730+
mock_pool.mark_agent_idle.return_value = None
731+
mock_pool.get_agent_status.return_value = {
732+
"test-agent-001": {"status": "idle", "agent_type": "backend"}
733+
}
734+
735+
# Mock agent instance with execute_task
736+
mock_agent_instance = Mock()
737+
mock_agent_instance.execute_task = AsyncMock(return_value={"status": "completed"})
738+
mock_pool.get_agent_instance.return_value = mock_agent_instance
739+
740+
mock_pool_class.return_value = mock_pool
741+
742+
lead_agent = LeadAgent(
743+
project_id=project_id,
744+
db=real_db,
745+
api_key="sk-ant-test-key",
746+
ws_manager=None,
747+
)
748+
lead_agent.agent_pool_manager = mock_pool
749+
750+
# Also mock the review agent to avoid review step
751+
with patch.object(lead_agent.agent_pool_manager, "get_or_create_agent") as mock_get_agent:
752+
# First call returns worker agent, second call returns review agent
753+
mock_get_agent.side_effect = ["test-agent-001", "review-agent-001"]
754+
755+
mock_review_instance = Mock()
756+
mock_review_report = Mock()
757+
mock_review_report.status = "approved"
758+
mock_review_report.overall_score = 9.0
759+
mock_review_instance.execute_task = AsyncMock(return_value=mock_review_report)
760+
761+
def get_instance_side_effect(agent_id):
762+
if agent_id == "review-agent-001":
763+
return mock_review_instance
764+
return mock_agent_instance
765+
766+
mock_pool.get_agent_instance.side_effect = get_instance_side_effect
767+
768+
# Get the task object
769+
task = real_db.get_task(task_id)
770+
771+
# Execute _assign_and_execute_task
772+
retry_counts = {}
773+
result = await lead_agent._assign_and_execute_task(task, retry_counts)
774+
775+
assert result is True, "Task execution should succeed"
776+
777+
# CRITICAL ASSERTION: Verify assigned_to was set
778+
task_after = real_db.get_task(task_id)
779+
assert task_after.assigned_to == "test-agent-001", (
780+
f"Task assigned_to should be 'test-agent-001' but was '{task_after.assigned_to}'. "
781+
"This is the Issue #248 bug - assigned_to field not being populated."
782+
)
783+
784+
@pytest.mark.asyncio
785+
async def test_assign_and_execute_task_sets_assigned_to_before_in_progress(
786+
self, real_db: Database, test_workspace: Path
787+
):
788+
"""Test that assigned_to is set before status changes to in_progress.
789+
790+
The UI needs to show assignment even during the brief period before
791+
task execution begins.
792+
"""
793+
from codeframe.agents.lead_agent import LeadAgent
794+
795+
# Setup project
796+
project_id = real_db.create_project(
797+
name="assigned-to-order-test",
798+
description="Test assigned_to ordering",
799+
source_type="empty",
800+
workspace_path=str(test_workspace),
801+
)
802+
803+
issue_id = real_db.create_issue({
804+
"project_id": project_id,
805+
"issue_number": "AO-001",
806+
"title": "Order Test Issue",
807+
"description": "Test",
808+
"priority": 1,
809+
"workflow_step": 1,
810+
})
811+
812+
task_id = real_db.create_task_with_issue(
813+
project_id=project_id,
814+
issue_id=issue_id,
815+
task_number="AO-001-1",
816+
parent_issue_number="AO-001",
817+
title="Order Test Task",
818+
description="Test ordering",
819+
status=TaskStatus.PENDING,
820+
priority=1,
821+
workflow_step=1,
822+
can_parallelize=True,
823+
)
824+
825+
# Track database update calls to verify ordering
826+
update_calls = []
827+
original_update_task = real_db.update_task
828+
829+
def tracking_update_task(task_id, updates):
830+
update_calls.append((task_id, updates.copy()))
831+
return original_update_task(task_id, updates)
832+
833+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test-key"}):
834+
with patch("codeframe.agents.lead_agent.AgentPoolManager") as mock_pool_class:
835+
mock_pool = Mock()
836+
mock_pool.get_or_create_agent.return_value = "order-agent-001"
837+
mock_pool.mark_agent_busy.return_value = None
838+
mock_pool.mark_agent_idle.return_value = None
839+
mock_pool.get_agent_status.return_value = {}
840+
841+
mock_agent_instance = Mock()
842+
mock_agent_instance.execute_task = AsyncMock(return_value={"status": "completed"})
843+
844+
mock_review_instance = Mock()
845+
mock_review_report = Mock()
846+
mock_review_report.status = "approved"
847+
mock_review_report.overall_score = 9.0
848+
mock_review_instance.execute_task = AsyncMock(return_value=mock_review_report)
849+
850+
def get_agent_side_effect(agent_type):
851+
if agent_type == "review":
852+
return "review-agent-001"
853+
return "order-agent-001"
854+
855+
mock_pool.get_or_create_agent.side_effect = get_agent_side_effect
856+
857+
def get_instance_side_effect(agent_id):
858+
if agent_id == "review-agent-001":
859+
return mock_review_instance
860+
return mock_agent_instance
861+
862+
mock_pool.get_agent_instance.side_effect = get_instance_side_effect
863+
864+
mock_pool_class.return_value = mock_pool
865+
866+
lead_agent = LeadAgent(
867+
project_id=project_id,
868+
db=real_db,
869+
api_key="sk-ant-test-key",
870+
ws_manager=None,
871+
)
872+
lead_agent.agent_pool_manager = mock_pool
873+
874+
# Patch update_task to track calls
875+
with patch.object(real_db, "update_task", side_effect=tracking_update_task):
876+
task = real_db.get_task(task_id)
877+
retry_counts = {}
878+
await lead_agent._assign_and_execute_task(task, retry_counts)
879+
880+
# Verify update order: assigned_to should be set before or with in_progress
881+
assigned_to_index = None
882+
in_progress_index = None
883+
884+
for i, (tid, updates) in enumerate(update_calls):
885+
if "assigned_to" in updates:
886+
assigned_to_index = i
887+
if updates.get("status") == "in_progress":
888+
in_progress_index = i
889+
890+
assert assigned_to_index is not None, "assigned_to should be updated"
891+
assert in_progress_index is not None, "status should be updated to in_progress"
892+
assert assigned_to_index <= in_progress_index, (
893+
f"assigned_to (call {assigned_to_index}) should be set before or with "
894+
f"in_progress (call {in_progress_index})"
895+
)

0 commit comments

Comments
 (0)