Fixed issues reported by agents

This commit is contained in:
Renn F
2025-12-24 04:23:16 +01:00
parent 06720b1978
commit afde0d5441
16 changed files with 277 additions and 94 deletions
+2 -2
View File
@@ -56,7 +56,7 @@ def upgrade() -> None:
), ),
sa.Column( sa.Column(
"team", "team",
sa.Enum("backend", "frontend", "ux_ui", "board", name="team"), sa.Enum("backend", "frontend", "ux_ui", "main_pm", "board", "marketing", name="team"),
nullable=True, nullable=True,
), ),
sa.Column( sa.Column(
@@ -126,7 +126,7 @@ def upgrade() -> None:
sa.Column( sa.Column(
"team", "team",
sa.Enum( sa.Enum(
"backend", "frontend", "ux_ui", "board", name="team", create_type=False "backend", "frontend", "ux_ui", "main_pm", "board", "marketing", name="team", create_type=False
), ),
nullable=False, nullable=False,
index=True, index=True,
+91
View File
@@ -50,6 +50,97 @@ services:
entrypoint: ["/bin/sh", "-c", "echo 'Agent base image built successfully'"] entrypoint: ["/bin/sh", "-c", "echo 'Agent base image built successfully'"]
restart: "no" restart: "no"
# ==========================================================================
# Agent PM Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-pm-image:
build:
context: .
dockerfile: docker/agent-pm.Dockerfile
image: roboco-agent-pm
entrypoint: ["/bin/sh", "-c", "echo 'Agent PM image built'"]
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Backend Dev Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-dev-be-image:
build:
context: .
dockerfile: docker/agent-dev-be.Dockerfile
image: roboco-agent-dev-be
entrypoint: ["/bin/sh", "-c", "echo 'Agent Backend Dev image built'"]
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Frontend Dev Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-dev-fe-image:
build:
context: .
dockerfile: docker/agent-dev-fe.Dockerfile
image: roboco-agent-dev-fe
entrypoint: ["/bin/sh", "-c", "echo 'Agent Frontend Dev image built'"]
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Backend QA Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-qa-be-image:
build:
context: .
dockerfile: docker/agent-qa-be.Dockerfile
image: roboco-agent-qa-be
entrypoint: ["/bin/sh", "-c", 'echo "Agent Backend QA image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Frontend QA Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-qa-fe-image:
build:
context: .
dockerfile: docker/agent-qa-fe.Dockerfile
image: roboco-agent-qa-fe
entrypoint: ["/bin/sh", "-c", 'echo "Agent Frontend QA image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent UX/UI Dev Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-ux-image:
build:
context: .
dockerfile: docker/agent-ux.Dockerfile
image: roboco-agent-ux
entrypoint: ["/bin/sh", "-c", 'echo "Agent UX/UI image built"']
restart: "no"
depends_on:
- agent-base-image
# ==========================================================================
# Agent Documenter Image Builder (specialized image built on-demand by orchestrator)
# ==========================================================================
agent-doc-image:
build:
context: .
dockerfile: docker/agent-doc.Dockerfile
image: roboco-agent-doc
entrypoint: ["/bin/sh", "-c", 'echo "Agent Documenter image built"']
restart: "no"
depends_on:
- agent-base-image
# ========================================================================== # ==========================================================================
# Orchestrator - API Server + Agent Spawner # Orchestrator - API Server + Agent Spawner
# ========================================================================== # ==========================================================================
+2 -6
View File
@@ -133,13 +133,9 @@ select = [
"RUF", # Ruff-specific "RUF", # Ruff-specific
] ]
# MCP servers: Tool functions require explicit parameters for schema generation. # Lazy imports to avoid circular dependencies
# PLC0415 - Lazy imports for circular import avoidance
#
# Services: Internal methods often need multiple related parameters for DB ops.
# PLC0415 - Lazy imports for circular import avoidance
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"roboco/mcp/*.py" = ["PLC0415"] "roboco/mcp/**/*.py" = ["PLC0415"]
"roboco/services/*.py" = ["PLC0415"] "roboco/services/*.py" = ["PLC0415"]
# ============================================================================= # =============================================================================
+1 -3
View File
@@ -500,9 +500,7 @@ Please review and provide guidance.
# Check if subtasks are complete (common blocker) # Check if subtasks are complete (common blocker)
subtasks = result.get("subtasks", []) subtasks = result.get("subtasks", [])
if subtasks: if subtasks:
all_complete = all( all_complete = all(s.get("status") == "completed" for s in subtasks)
s.get("status") == "completed" for s in subtasks
)
if all_complete: if all_complete:
return True return True
+3 -1
View File
@@ -92,7 +92,9 @@ async def list_sessions(
query = ( query = (
select(SessionTable) select(SessionTable)
.where(SessionTable.group_id == params.group_id) .where(SessionTable.group_id == params.group_id)
.options(selectinload(SessionTable.task_links).selectinload(SessionTaskTable.task)) .options(
selectinload(SessionTable.task_links).selectinload(SessionTaskTable.task)
)
) )
if params.status_filter: if params.status_filter:
+2 -1
View File
@@ -90,6 +90,7 @@ async def create_task(
created_by=agent.agent_id, created_by=agent.agent_id,
priority=data.priority, priority=data.priority,
parent_task_id=data.parent_task_id, parent_task_id=data.parent_task_id,
assigned_to=data.assigned_to,
target_date=data.target_date, target_date=data.target_date,
estimated_complexity=data.estimated_complexity, estimated_complexity=data.estimated_complexity,
status=data.status, status=data.status,
@@ -948,7 +949,7 @@ async def complete_task(
incomplete_subtasks = [ incomplete_subtasks = [
st st
for st in subtasks for st in subtasks
if st.status not in (TaskStatus.completed, TaskStatus.cancelled) if st.status not in (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
] ]
if incomplete_subtasks: if incomplete_subtasks:
max_titles_shown = 3 max_titles_shown = 3
+53 -42
View File
@@ -50,6 +50,47 @@ def _is_same_cell(agent1: str, agent2: str) -> bool:
return cell1 is not None and cell1 == cell2 return cell1 is not None and cell1 == cell2
# Roles with global read access (can read all non-protected journals)
GLOBAL_READERS = frozenset(
["ceo", "auditor", "product_owner", "head_marketing", "main_pm"]
)
# Roles that can read cross-cell PM journals
PM_ROLES = frozenset(["cell_pm", "main_pm"])
# Cell member roles (can only read same-cell journals)
CELL_MEMBER_ROLES = frozenset(["developer", "qa", "documenter"])
def _check_protected_access(
reader_role: str, owner_id: str, owner_role: str
) -> tuple[bool, str] | None:
"""Check access to protected journals. Returns None if not protected."""
if owner_id not in PROTECTED_JOURNALS and owner_role not in ("ceo", "auditor"):
return None # Not a protected journal
if reader_role in ("ceo", "auditor"):
return True, "OK"
return False, f"Cannot read {owner_role}'s journal - protected"
def _check_cell_pm_access(
reader_id: str, owner_id: str, owner_role: str
) -> tuple[bool, str]:
"""Check Cell PM's access to another journal."""
if _is_same_cell(reader_id, owner_id):
return True, "OK"
if owner_role in PM_ROLES:
return True, "OK"
return False, "Cell PM can only read journals of cell members, other PMs"
def _check_cell_member_access(reader_id: str, owner_id: str) -> tuple[bool, str]:
"""Check cell member's access to another journal."""
if _is_same_cell(reader_id, owner_id):
return True, "OK"
return False, "You can only read journals of your cell members"
def can_read_journal(reader_id: str, owner_id: str) -> tuple[bool, str]: def can_read_journal(reader_id: str, owner_id: str) -> tuple[bool, str]:
""" """
Check if reader can access owner's journal. Check if reader can access owner's journal.
@@ -57,59 +98,29 @@ def can_read_journal(reader_id: str, owner_id: str) -> tuple[bool, str]:
Returns: Returns:
Tuple of (can_read, reason) Tuple of (can_read, reason)
""" """
# Self-access always allowed
if reader_id == owner_id: if reader_id == owner_id:
return True, "OK" return True, "OK"
reader_role = get_agent_role(reader_id) reader_role = get_agent_role(reader_id)
owner_role = get_agent_role(owner_id) owner_role = get_agent_role(owner_id)
# CEO and Auditor journals are protected # Check protected journals first
if owner_id in PROTECTED_JOURNALS or owner_role in ("ceo", "auditor"): if (
# Only CEO can read Auditor's journal and vice versa result := _check_protected_access(reader_role, owner_id, owner_role)
if reader_role == "ceo": ) is not None:
return True, "OK" return result
if reader_role == "auditor":
return True, "OK"
return False, f"Cannot read {owner_role}'s journal - protected"
# CEO can read all journals (except protected, handled above) # Global readers can access all non-protected journals
if reader_role == "ceo": if reader_role in GLOBAL_READERS:
return True, "OK" return True, "OK"
# Auditor has silent read access to all journals # Cell PM access rules
if reader_role == "auditor":
return True, "OK"
# Board members can read all cell journals
if reader_role in ("product_owner", "head_marketing"):
return True, "OK"
# Main PM can read all cell journals
if reader_role == "main_pm":
return True, "OK"
# Cell PM can read:
# 1. Own cell members
# 2. Other Cell PMs
# 3. Main PM (for coordination)
if reader_role == "cell_pm": if reader_role == "cell_pm":
# Own cell members return _check_cell_pm_access(reader_id, owner_id, owner_role)
if _is_same_cell(reader_id, owner_id):
return True, "OK"
# Other Cell PMs
if owner_role == "cell_pm":
return True, "OK"
# Main PM
if owner_role == "main_pm":
return True, "OK"
return False, "Cell PM can only read journals of cell members, other PMs"
# Cell members (developer, qa, documenter) can read same cell journals # Cell member access rules
if reader_role in ("developer", "qa", "documenter"): if reader_role in CELL_MEMBER_ROLES:
if _is_same_cell(reader_id, owner_id): return _check_cell_member_access(reader_id, owner_id)
return True, "OK"
return False, "You can only read journals of your cell members"
return False, "Unknown role - access denied" return False, "Unknown role - access denied"
+27 -10
View File
@@ -352,53 +352,70 @@ def create_journal_mcp_server(agent_id: str) -> FastMCP:
client = ApiClient(agent_id) client = ApiClient(agent_id)
@mcp.tool() @mcp.tool()
async def roboco_journal_entry(data: JournalEntryInput) -> dict[str, Any]: async def roboco_journal_entry(entry: JournalEntryInput) -> dict[str, Any]:
""" """
Create a general journal entry. Create a general journal entry.
Your journal is personal - use it to track thoughts, progress, Your journal is personal - use it to track thoughts, progress,
and document your journey on tasks. and document your journey on tasks.
Args:
entry: Journal entry with title, content, entry_type, task_id, tags
""" """
return await _handle_journal_entry(data, client) return await _handle_journal_entry(entry, client)
@mcp.tool() @mcp.tool()
async def roboco_journal_reflect(data: TaskReflectionInput) -> dict[str, Any]: async def roboco_journal_reflect(reflection: TaskReflectionInput) -> dict[str, Any]:
""" """
Add a task reflection entry. Add a task reflection entry.
IMPORTANT: Call this when completing a task. Reflections help build IMPORTANT: Call this when completing a task. Reflections help build
institutional memory and track your growth. institutional memory and track your growth.
Args:
reflection: Reflection with task_id, title, what_done, what_learned,
what_struggled, next_steps
""" """
return await _handle_reflect(data, client) return await _handle_reflect(reflection, client)
@mcp.tool() @mcp.tool()
async def roboco_journal_decision(data: DecisionLogInput) -> dict[str, Any]: async def roboco_journal_decision(decision: DecisionLogInput) -> dict[str, Any]:
""" """
Log a decision you made. Log a decision you made.
Use when choosing between approaches. Creates a record of WHY Use when choosing between approaches. Creates a record of WHY
you made the decision for future context. you made the decision for future context.
Args:
decision: Decision log with title, context, options, chosen, rationale
""" """
return await _handle_decision(data, client) return await _handle_decision(decision, client)
@mcp.tool() @mcp.tool()
async def roboco_journal_learning(data: LearningInput) -> dict[str, Any]: async def roboco_journal_learning(learning: LearningInput) -> dict[str, Any]:
""" """
Log something you learned. Log something you learned.
Track learnings to build your knowledge base and help future you. Track learnings to build your knowledge base and help future you.
Args:
learning: Learning entry with title, what_learned, how_applied, source
""" """
return await _handle_learning(data, client) return await _handle_learning(learning, client)
@mcp.tool() @mcp.tool()
async def roboco_journal_struggle(data: StruggleInput) -> dict[str, Any]: async def roboco_journal_struggle(struggle: StruggleInput) -> dict[str, Any]:
""" """
Log a struggle or challenge. Log a struggle or challenge.
Recording struggles helps track problem-solving patterns and Recording struggles helps track problem-solving patterns and
create documentation for others. create documentation for others.
Args:
struggle: Struggle entry with title, what_struggled,
attempted_solutions, resolution, help_needed
""" """
return await _handle_struggle(data, client) return await _handle_struggle(struggle, client)
@mcp.tool() @mcp.tool()
async def roboco_journal_search(query: str, top_k: int = 5) -> dict[str, Any]: async def roboco_journal_search(query: str, top_k: int = 5) -> dict[str, Any]:
+14 -1
View File
@@ -222,7 +222,20 @@ async def validate_task_start(
if task_status == "claimed" and not task.get("plan"): if task_status == "claimed" and not task.get("plan"):
return format_error_response( return format_error_response(
"NO_PLAN", "Cannot start without a plan. Call roboco_task_plan first." "NO_PLAN",
"Cannot start without a plan.",
{
"required_action": "roboco_task_plan(task_id, approach, steps)",
"workflow": "claim → PLAN → start",
"example": {
"task_id": task.get("id"),
"approach": "Describe your implementation approach",
"steps": [
{"title": "Step 1", "description": "What to do first"},
{"title": "Step 2", "description": "What to do next"},
],
},
},
) )
if task_status == "claimed": if task_status == "claimed":
+6 -5
View File
@@ -72,10 +72,11 @@ async def handle_task_claim(
return format_task_response( return format_task_response(
claimed_task, claimed_task,
"UNDERSTAND", "PLAN",
"Task claimed successfully. " "Task claimed. NEXT: Call roboco_task_plan() before you can start.\n"
"Read the description and acceptance criteria carefully. " "1. Read the description and acceptance criteria\n"
"Ask questions if ANYTHING is unclear - do not guess. " "2. Ask questions if anything is unclear\n"
"When ready, create your plan with roboco_task_plan.", "3. Call roboco_task_plan(task_id, approach, steps)\n"
"4. Then call roboco_task_start(task_id)",
project=project, project=project,
) )
+1
View File
@@ -48,6 +48,7 @@ class Team(str, Enum):
BACKEND = "backend" BACKEND = "backend"
FRONTEND = "frontend" FRONTEND = "frontend"
UX_UI = "ux_ui" UX_UI = "ux_ui"
MAIN_PM = "main_pm" # Main PM level - cross-cell coordination
BOARD = "board" BOARD = "board"
MARKETING = "marketing" MARKETING = "marketing"
+2
View File
@@ -232,6 +232,7 @@ class TaskCreate(RobocoBase):
team: Team team: Team
priority: int = Field(default=2, ge=0, le=3) priority: int = Field(default=2, ge=0, le=3)
parent_task_id: UUID | None = None parent_task_id: UUID | None = None
assigned_to: UUID | None = None # Optional: assign on creation
target_date: datetime | None = None target_date: datetime | None = None
estimated_complexity: Complexity = Complexity.MEDIUM estimated_complexity: Complexity = Complexity.MEDIUM
status: TaskStatus | None = None # PM can set 'backlog' for subtasks needing setup status: TaskStatus | None = None # PM can set 'backlog' for subtasks needing setup
@@ -269,6 +270,7 @@ class TaskCreateRequest:
created_by: UUID created_by: UUID
priority: int = 2 priority: int = 2
parent_task_id: UUID | None = None parent_task_id: UUID | None = None
assigned_to: UUID | None = None
target_date: datetime | None = None target_date: datetime | None = None
estimated_complexity: Complexity = field(default=Complexity.MEDIUM) estimated_complexity: Complexity = field(default=Complexity.MEDIUM)
status: TaskStatus | None = None # PM can set BACKLOG for subtasks status: TaskStatus | None = None # PM can set BACKLOG for subtasks
+42 -12
View File
@@ -77,10 +77,12 @@ AGENT_IMAGES: dict[str, str] = {
"auditor": "roboco-agent-pm", "auditor": "roboco-agent-pm",
} }
def get_agent_image(agent_id: str) -> str: def get_agent_image(agent_id: str) -> str:
"""Get the Docker image for an agent.""" """Get the Docker image for an agent."""
return AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE) return AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE)
# When running in a container, we need host paths for volume mounts. # When running in a container, we need host paths for volume mounts.
# These can be overridden via environment variables. # These can be overridden via environment variables.
CLAUDE_AUTH_HOST_PATH = os.environ.get( CLAUDE_AUTH_HOST_PATH = os.environ.get(
@@ -665,14 +667,25 @@ class AgentOrchestrator:
} }
return role_map.get(agent_id, agent_id) return role_map.get(agent_id, agent_id)
# Static team mappings for management agents
_AGENT_TEAM_MAP: ClassVar[dict[str, str]] = {
"main-pm": "main_pm",
"product-owner": "board",
"auditor": "board",
"head-marketing": "marketing",
}
def _get_agent_team(self, agent_id: str) -> str | None: def _get_agent_team(self, agent_id: str) -> str | None:
"""Get team from agent_id.""" """Get team from agent_id."""
if agent_id.startswith("be-"): # Check static mappings first
return "backend" if agent_id in self._AGENT_TEAM_MAP:
if agent_id.startswith("fe-"): return self._AGENT_TEAM_MAP[agent_id]
return "frontend"
if agent_id.startswith("ux-"): # Check cell prefixes
return "ux_ui" prefix_map = {"be-": "backend", "fe-": "frontend", "ux-": "ux_ui"}
for prefix, team in prefix_map.items():
if agent_id.startswith(prefix):
return team
return None return None
def _resolve_agent_slug(self, agent_id_or_uuid: str) -> str: def _resolve_agent_slug(self, agent_id_or_uuid: str) -> str:
@@ -1150,17 +1163,30 @@ Start by:
"""Check if text contains PM coordination keywords.""" """Check if text contains PM coordination keywords."""
return any(kw in text for kw in self._PM_KEYWORDS) return any(kw in text for kw in self._PM_KEYWORDS)
# Direct team-to-routing mappings (explicit assignments bypass keyword analysis)
_TEAM_ROUTING_MAP: ClassVar[dict[str, str]] = {
"main_pm": "main_pm",
"board": "board",
"marketing": "marketing",
}
def _classify_task_routing(self, task: dict[str, Any]) -> str: def _classify_task_routing(self, task: dict[str, Any]) -> str:
""" """
Classify a task for routing based on complexity, keywords, and team. Classify a task for routing based on team, complexity, and keywords.
Returns one of: "board", "main_pm", "cell_pm", "dev" Returns one of: "board", "main_pm", "cell_pm", "dev", "marketing"
""" """
team = task.get("team")
# Explicit team assignment takes precedence
if team in self._TEAM_ROUTING_MAP:
return self._TEAM_ROUTING_MAP[team]
# For cell teams, use keyword/complexity analysis
title = (task.get("title") or "").lower() title = (task.get("title") or "").lower()
description = (task.get("description") or "").lower() description = (task.get("description") or "").lower()
text = f"{title} {description}" text = f"{title} {description}"
complexity = task.get("complexity", "medium").lower() complexity = task.get("estimated_complexity", "medium").lower()
team = task.get("team")
# Board-level keywords → Board # Board-level keywords → Board
if self._has_board_keywords(text): if self._has_board_keywords(text):
@@ -1189,7 +1215,7 @@ Start by:
Resolve a routing decision to a specific agent slug. Resolve a routing decision to a specific agent slug.
Args: Args:
routing: One of "board", "main_pm", "cell_pm", "dev" routing: One of "board", "main_pm", "cell_pm", "dev", "marketing"
task: The task being routed task: The task being routed
Returns: Returns:
@@ -1198,7 +1224,11 @@ Start by:
team = task.get("team") team = task.get("team")
# Static routing targets # Static routing targets
static_targets = {"board": "product-owner", "main_pm": "main-pm"} static_targets = {
"board": "product-owner",
"main_pm": "main-pm",
"marketing": "head-marketing",
}
if routing in static_targets: if routing in static_targets:
return static_targets[routing] return static_targets[routing]
+4 -4
View File
@@ -237,28 +237,28 @@ DEFAULT_AGENTS: list[dict[str, Any]] = [
"slug": "main-pm", "slug": "main-pm",
"name": "Main PM", "name": "Main PM",
"role": "main_pm", "role": "main_pm",
"team": None, "team": "main_pm", # Cross-cell coordination
}, },
{ {
"id": AGENT_UUIDS["product-owner"], "id": AGENT_UUIDS["product-owner"],
"slug": "product-owner", "slug": "product-owner",
"name": "Product Owner", "name": "Product Owner",
"role": "product_owner", "role": "product_owner",
"team": None, "team": "board",
}, },
{ {
"id": AGENT_UUIDS["head-marketing"], "id": AGENT_UUIDS["head-marketing"],
"slug": "head-marketing", "slug": "head-marketing",
"name": "Head of Marketing", "name": "Head of Marketing",
"role": "head_marketing", "role": "head_marketing",
"team": None, "team": "marketing",
}, },
{ {
"id": AGENT_UUIDS["auditor"], "id": AGENT_UUIDS["auditor"],
"slug": "auditor", "slug": "auditor",
"name": "Auditor", "name": "Auditor",
"role": "auditor", "role": "auditor",
"team": None, "team": "board", # Silent observer, board-level access
}, },
# CEO (Human) # CEO (Human)
{ {
@@ -220,12 +220,12 @@ async def resolve_agent_identity(
result = await db.execute( result = await db.execute(
select(AgentTable.id).where(AgentTable.slug == agent_id_or_slug) select(AgentTable.id).where(AgentTable.slug == agent_id_or_slug)
) )
agent_uuid = result.scalar_one_or_none() found_id = result.scalar_one_or_none()
if agent_uuid is None: if found_id is None:
return None return None
return (UUID(str(agent_uuid)), agent_id_or_slug) return (UUID(str(found_id)), agent_id_or_slug)
async def get_agent_slug( async def get_agent_slug(
@@ -242,9 +242,7 @@ async def get_agent_slug(
Returns: Returns:
The agent's slug (e.g., "be-dev-1"), or None if not found The agent's slug (e.g., "be-dev-1"), or None if not found
""" """
result = await db.execute( result = await db.execute(select(AgentTable.slug).where(AgentTable.id == agent_id))
select(AgentTable.slug).where(AgentTable.id == agent_id)
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
+23 -1
View File
@@ -127,6 +127,7 @@ class TaskService(BaseService):
acceptance_criteria=req.acceptance_criteria, acceptance_criteria=req.acceptance_criteria,
team=req.team, team=req.team,
created_by=req.created_by, created_by=req.created_by,
assigned_to=req.assigned_to,
priority=req.priority, priority=req.priority,
parent_task_id=req.parent_task_id, parent_task_id=req.parent_task_id,
target_date=req.target_date, target_date=req.target_date,
@@ -322,16 +323,37 @@ class TaskService(BaseService):
return "invalid status for role" return "invalid status for role"
return None return None
# Management roles that can claim tasks from any team
_MANAGEMENT_ROLES = frozenset(
{"main_pm", "product_owner", "head_marketing", "auditor"}
)
def _validate_claim_team( def _validate_claim_team(
self, task: TaskTable, agent: AgentTable | None self, task: TaskTable, agent: AgentTable | None
) -> str | None: ) -> str | None:
""" """
Validate agent belongs to task's team. Validate agent belongs to task's team.
Management roles (main_pm, product_owner, head_marketing, auditor)
can claim tasks from any team.
Returns: Returns:
Error message if invalid, None if valid Error message if invalid, None if valid
""" """
if agent and task.team and agent.team != task.team: if not agent or not task.team:
return None
# Get agent role as string
agent_role = (
agent.role.value if hasattr(agent.role, "value") else str(agent.role)
)
# Management roles can claim any task
if agent_role in self._MANAGEMENT_ROLES:
return None
# Regular agents must match team
if agent.team != task.team:
return "agent not in task's team" return "agent not in task's team"
return None return None