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(
"team",
sa.Enum("backend", "frontend", "ux_ui", "board", name="team"),
sa.Enum("backend", "frontend", "ux_ui", "main_pm", "board", "marketing", name="team"),
nullable=True,
),
sa.Column(
@@ -126,7 +126,7 @@ def upgrade() -> None:
sa.Column(
"team",
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,
index=True,
+91
View File
@@ -50,6 +50,97 @@ services:
entrypoint: ["/bin/sh", "-c", "echo 'Agent base image built successfully'"]
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
# ==========================================================================
+2 -6
View File
@@ -133,13 +133,9 @@ select = [
"RUF", # Ruff-specific
]
# MCP servers: Tool functions require explicit parameters for schema generation.
# 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
# Lazy imports to avoid circular dependencies
[tool.ruff.lint.per-file-ignores]
"roboco/mcp/*.py" = ["PLC0415"]
"roboco/mcp/**/*.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)
subtasks = result.get("subtasks", [])
if subtasks:
all_complete = all(
s.get("status") == "completed" for s in subtasks
)
all_complete = all(s.get("status") == "completed" for s in subtasks)
if all_complete:
return True
+3 -1
View File
@@ -92,7 +92,9 @@ async def list_sessions(
query = (
select(SessionTable)
.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:
+2 -1
View File
@@ -90,6 +90,7 @@ async def create_task(
created_by=agent.agent_id,
priority=data.priority,
parent_task_id=data.parent_task_id,
assigned_to=data.assigned_to,
target_date=data.target_date,
estimated_complexity=data.estimated_complexity,
status=data.status,
@@ -948,7 +949,7 @@ async def complete_task(
incomplete_subtasks = [
st
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:
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
# 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]:
"""
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:
Tuple of (can_read, reason)
"""
# Self-access always allowed
if reader_id == owner_id:
return True, "OK"
reader_role = get_agent_role(reader_id)
owner_role = get_agent_role(owner_id)
# CEO and Auditor journals are protected
if owner_id in PROTECTED_JOURNALS or owner_role in ("ceo", "auditor"):
# Only CEO can read Auditor's journal and vice versa
if reader_role == "ceo":
return True, "OK"
if reader_role == "auditor":
return True, "OK"
return False, f"Cannot read {owner_role}'s journal - protected"
# Check protected journals first
if (
result := _check_protected_access(reader_role, owner_id, owner_role)
) is not None:
return result
# CEO can read all journals (except protected, handled above)
if reader_role == "ceo":
# Global readers can access all non-protected journals
if reader_role in GLOBAL_READERS:
return True, "OK"
# Auditor has silent read access to all journals
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)
# Cell PM access rules
if reader_role == "cell_pm":
# Own cell members
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"
return _check_cell_pm_access(reader_id, owner_id, owner_role)
# Cell members (developer, qa, documenter) can read same cell journals
if reader_role in ("developer", "qa", "documenter"):
if _is_same_cell(reader_id, owner_id):
return True, "OK"
return False, "You can only read journals of your cell members"
# Cell member access rules
if reader_role in CELL_MEMBER_ROLES:
return _check_cell_member_access(reader_id, owner_id)
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)
@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.
Your journal is personal - use it to track thoughts, progress,
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()
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.
IMPORTANT: Call this when completing a task. Reflections help build
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()
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.
Use when choosing between approaches. Creates a record of WHY
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()
async def roboco_journal_learning(data: LearningInput) -> dict[str, Any]:
async def roboco_journal_learning(learning: LearningInput) -> dict[str, Any]:
"""
Log something you learned.
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()
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.
Recording struggles helps track problem-solving patterns and
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()
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"):
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":
+6 -5
View File
@@ -72,10 +72,11 @@ async def handle_task_claim(
return format_task_response(
claimed_task,
"UNDERSTAND",
"Task claimed successfully. "
"Read the description and acceptance criteria carefully. "
"Ask questions if ANYTHING is unclear - do not guess. "
"When ready, create your plan with roboco_task_plan.",
"PLAN",
"Task claimed. NEXT: Call roboco_task_plan() before you can start.\n"
"1. Read the description and acceptance criteria\n"
"2. Ask questions if anything is unclear\n"
"3. Call roboco_task_plan(task_id, approach, steps)\n"
"4. Then call roboco_task_start(task_id)",
project=project,
)
+1
View File
@@ -48,6 +48,7 @@ class Team(str, Enum):
BACKEND = "backend"
FRONTEND = "frontend"
UX_UI = "ux_ui"
MAIN_PM = "main_pm" # Main PM level - cross-cell coordination
BOARD = "board"
MARKETING = "marketing"
+2
View File
@@ -232,6 +232,7 @@ class TaskCreate(RobocoBase):
team: Team
priority: int = Field(default=2, ge=0, le=3)
parent_task_id: UUID | None = None
assigned_to: UUID | None = None # Optional: assign on creation
target_date: datetime | None = None
estimated_complexity: Complexity = Complexity.MEDIUM
status: TaskStatus | None = None # PM can set 'backlog' for subtasks needing setup
@@ -269,6 +270,7 @@ class TaskCreateRequest:
created_by: UUID
priority: int = 2
parent_task_id: UUID | None = None
assigned_to: UUID | None = None
target_date: datetime | None = None
estimated_complexity: Complexity = field(default=Complexity.MEDIUM)
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",
}
def get_agent_image(agent_id: str) -> str:
"""Get the Docker image for an agent."""
return AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE)
# When running in a container, we need host paths for volume mounts.
# These can be overridden via environment variables.
CLAUDE_AUTH_HOST_PATH = os.environ.get(
@@ -665,14 +667,25 @@ class AgentOrchestrator:
}
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:
"""Get team from agent_id."""
if agent_id.startswith("be-"):
return "backend"
if agent_id.startswith("fe-"):
return "frontend"
if agent_id.startswith("ux-"):
return "ux_ui"
# Check static mappings first
if agent_id in self._AGENT_TEAM_MAP:
return self._AGENT_TEAM_MAP[agent_id]
# Check cell prefixes
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
def _resolve_agent_slug(self, agent_id_or_uuid: str) -> str:
@@ -1150,17 +1163,30 @@ Start by:
"""Check if text contains PM coordination 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:
"""
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()
description = (task.get("description") or "").lower()
text = f"{title} {description}"
complexity = task.get("complexity", "medium").lower()
team = task.get("team")
complexity = task.get("estimated_complexity", "medium").lower()
# Board-level keywords → Board
if self._has_board_keywords(text):
@@ -1189,7 +1215,7 @@ Start by:
Resolve a routing decision to a specific agent slug.
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
Returns:
@@ -1198,7 +1224,11 @@ Start by:
team = task.get("team")
# 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:
return static_targets[routing]
+4 -4
View File
@@ -237,28 +237,28 @@ DEFAULT_AGENTS: list[dict[str, Any]] = [
"slug": "main-pm",
"name": "Main PM",
"role": "main_pm",
"team": None,
"team": "main_pm", # Cross-cell coordination
},
{
"id": AGENT_UUIDS["product-owner"],
"slug": "product-owner",
"name": "Product Owner",
"role": "product_owner",
"team": None,
"team": "board",
},
{
"id": AGENT_UUIDS["head-marketing"],
"slug": "head-marketing",
"name": "Head of Marketing",
"role": "head_marketing",
"team": None,
"team": "marketing",
},
{
"id": AGENT_UUIDS["auditor"],
"slug": "auditor",
"name": "Auditor",
"role": "auditor",
"team": None,
"team": "board", # Silent observer, board-level access
},
# CEO (Human)
{
@@ -220,12 +220,12 @@ async def resolve_agent_identity(
result = await db.execute(
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 (UUID(str(agent_uuid)), agent_id_or_slug)
return (UUID(str(found_id)), agent_id_or_slug)
async def get_agent_slug(
@@ -242,9 +242,7 @@ async def get_agent_slug(
Returns:
The agent's slug (e.g., "be-dev-1"), or None if not found
"""
result = await db.execute(
select(AgentTable.slug).where(AgentTable.id == agent_id)
)
result = await db.execute(select(AgentTable.slug).where(AgentTable.id == agent_id))
return result.scalar_one_or_none()
+23 -1
View File
@@ -127,6 +127,7 @@ class TaskService(BaseService):
acceptance_criteria=req.acceptance_criteria,
team=req.team,
created_by=req.created_by,
assigned_to=req.assigned_to,
priority=req.priority,
parent_task_id=req.parent_task_id,
target_date=req.target_date,
@@ -322,16 +323,37 @@ class TaskService(BaseService):
return "invalid status for role"
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(
self, task: TaskTable, agent: AgentTable | None
) -> str | None:
"""
Validate agent belongs to task's team.
Management roles (main_pm, product_owner, head_marketing, auditor)
can claim tasks from any team.
Returns:
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 None