mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fixed escalation bug
This commit is contained in:
@@ -6,19 +6,19 @@ DEFAULT_PYTHON = 3.10
|
||||
.PHONY: install
|
||||
install:
|
||||
@uv sync
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# Install dev dependencies
|
||||
.PHONY: install-dev
|
||||
install-dev:
|
||||
@uv sync --extra dev
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# Update dependencies
|
||||
.PHONY: lock
|
||||
lock:
|
||||
@uv lock
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
|
||||
# Upgrade dependencies
|
||||
@@ -26,7 +26,7 @@ lock:
|
||||
upgrade:
|
||||
@uv lock --upgrade
|
||||
@uv sync --all-extras
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# =============================================================================
|
||||
# INFRASTRUCTURE
|
||||
@@ -165,7 +165,7 @@ fix:
|
||||
@echo "Fixing formatting w/ Ruff..."
|
||||
@echo ''
|
||||
@uv run ruff check --fix .
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# Find dead code with Vulture
|
||||
.PHONY: vulture
|
||||
@@ -340,19 +340,19 @@ high-load-stress-test:
|
||||
.PHONY: serve-docs
|
||||
serve-docs:
|
||||
@uv run mkdocs serve
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# Lint documentation
|
||||
.PHONY: lint-docs
|
||||
lint-docs:
|
||||
@uv run pymarkdownlnt scan -r -e ./.venv -e ./.git -e ./.github -e ./roboco -e ./tests -e ./.claude -e ./CLAUDE.md -e ./ZZZ .
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# Fix documentation
|
||||
.PHONY: fix-docs
|
||||
fix-docs:
|
||||
@uv run pymarkdownlnt fix -r -e ./.venv -e ./.git -e ./.github -e ./roboco -e ./tests -e ./.claude -e ./CLAUDE.md -e ./ZZZ .
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# Prune
|
||||
.PHONY: prune
|
||||
@@ -362,7 +362,7 @@ prune:
|
||||
# Clean Cache Files
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@find . | grep -E "(__pycache__|\\.pyc|\\.pyo|\\.pytest_cache|\\.ruff_cache|\\.mypy_cache)" | xargs rm -rf
|
||||
@find . | grep -E "(__pycache__|\.pyc|\.pyo|\.pytest_cache|\.ruff_cache|\.mypy_cache)" | xargs rm -rf
|
||||
|
||||
# Help
|
||||
.PHONY: help
|
||||
|
||||
+101
-1
@@ -10,6 +10,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.agents_config import get_escalation_target
|
||||
from roboco.api.deps import (
|
||||
CurrentAgentContext,
|
||||
DbSession,
|
||||
@@ -24,6 +25,8 @@ from roboco.api.schemas.tasks import (
|
||||
CheckpointRequest,
|
||||
ClaimRequest,
|
||||
CommitRequest,
|
||||
EscalateRequest,
|
||||
EscalateResponse,
|
||||
ProgressRequest,
|
||||
QANotes,
|
||||
SoftBlockRequest,
|
||||
@@ -36,7 +39,7 @@ from roboco.api.schemas.tasks import (
|
||||
task_to_response,
|
||||
transform_update_data,
|
||||
)
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.db.tables import AgentTable, NotificationTable
|
||||
from roboco.models.base import TaskStatus, Team
|
||||
from roboco.models.task import TaskCreate
|
||||
from roboco.services.audit import get_audit_service
|
||||
@@ -1013,6 +1016,103 @@ async def cancel_task(
|
||||
return task_to_response(task)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ESCALATION (ALL AGENTS CAN ESCALATE)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/{task_id}/escalate", response_model=EscalateResponse)
|
||||
async def escalate_task(
|
||||
task_id: UUID,
|
||||
data: EscalateRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> EscalateResponse:
|
||||
"""
|
||||
Escalate a task to PM/management.
|
||||
|
||||
IMPORTANT: Unlike normal notifications, escalation is available to ALL agents.
|
||||
This is a critical workflow tool for getting help when blocked.
|
||||
Permission checks are intentionally bypassed for escalation.
|
||||
|
||||
Escalation chain:
|
||||
- Developers → Cell PM
|
||||
- QA → Cell PM
|
||||
- Documenters → Cell PM
|
||||
- Cell PM → Main PM
|
||||
- Main PM → Product Owner
|
||||
- Product Owner → CEO
|
||||
"""
|
||||
# Verify task exists
|
||||
service = get_task_service(db)
|
||||
task = await service.get(task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||
)
|
||||
|
||||
# Get the agent's slug for escalation chain lookup
|
||||
agent_result = await db.execute(
|
||||
select(AgentTable).where(AgentTable.id == agent.agent_id)
|
||||
)
|
||||
agent_record = agent_result.scalar_one_or_none()
|
||||
if not agent_record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found"
|
||||
)
|
||||
|
||||
# Determine escalation target
|
||||
target_slug = data.escalate_to or get_escalation_target(agent_record.slug)
|
||||
if not target_slug:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"No escalation target configured for {agent_record.slug}",
|
||||
)
|
||||
|
||||
# Resolve target agent UUID
|
||||
target_result = await db.execute(
|
||||
select(AgentTable).where(AgentTable.slug == target_slug)
|
||||
)
|
||||
target_agent = target_result.scalar_one_or_none()
|
||||
if not target_agent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Escalation target not found: {target_slug}",
|
||||
)
|
||||
|
||||
# Create escalation notification directly (bypassing permission checks)
|
||||
body = (
|
||||
f"Task {task_id} escalated by {agent_record.slug}.\n\n"
|
||||
f"Reason: {data.reason}"
|
||||
)
|
||||
notification = NotificationTable(
|
||||
type="blocker_escalation",
|
||||
priority="high",
|
||||
from_agent=agent.agent_id,
|
||||
to_agents=[target_agent.id],
|
||||
subject=f"Escalation: {task.title or 'Unknown task'}",
|
||||
body=body,
|
||||
related_task_id=task_id,
|
||||
requires_ack=True,
|
||||
read_by=[],
|
||||
acked_by=[],
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
|
||||
msg = (
|
||||
f"Task escalated to {target_slug}. "
|
||||
"They will be notified and can reassign or provide guidance."
|
||||
)
|
||||
return EscalateResponse(
|
||||
status="escalated",
|
||||
task_id=task_id,
|
||||
escalated_to=target_slug,
|
||||
reason=data.reason,
|
||||
message=msg,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PROGRESS AND ARTIFACTS
|
||||
# =============================================================================
|
||||
|
||||
@@ -313,6 +313,30 @@ class SoftBlockRequest(BaseModel):
|
||||
what_needed: str = Field(..., description="What is needed to unblock the task")
|
||||
|
||||
|
||||
class EscalateRequest(BaseModel):
|
||||
"""Request to escalate a task to PM/management.
|
||||
|
||||
Escalation is available to ALL agents (devs, QA, documenters) when blocked.
|
||||
This bypasses normal notification permissions because escalation is a critical
|
||||
workflow tool for getting help when stuck.
|
||||
"""
|
||||
|
||||
reason: str = Field(..., description="Why the task is being escalated")
|
||||
escalate_to: str | None = Field(
|
||||
None, description="Target agent ID (defaults to cell PM)"
|
||||
)
|
||||
|
||||
|
||||
class EscalateResponse(BaseModel):
|
||||
"""Response from an escalation request."""
|
||||
|
||||
status: str
|
||||
task_id: UUID
|
||||
escalated_to: str
|
||||
reason: str
|
||||
message: str
|
||||
|
||||
|
||||
class TaskCountResponse(BaseModel):
|
||||
"""Task count by category."""
|
||||
|
||||
|
||||
@@ -287,52 +287,65 @@ def _build_escalation_notification(
|
||||
|
||||
|
||||
async def handle_task_escalate(
|
||||
client: ApiClient, input_data: TaskEscalateInput, agent_id: str
|
||||
client: ApiClient, input_data: TaskEscalateInput, _agent_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Handle task escalation up the hierarchy."""
|
||||
target, error = _get_escalation_target(agent_id, input_data.escalate_to)
|
||||
if error:
|
||||
return error
|
||||
assert target is not None
|
||||
"""Handle task escalation up the hierarchy.
|
||||
|
||||
target_uuid = await resolve_agent_uuid_cached(target, client)
|
||||
if not target_uuid:
|
||||
return format_error_response(
|
||||
"INVALID_TARGET",
|
||||
f"Could not resolve escalation target: {target}",
|
||||
Uses the dedicated /tasks/{task_id}/escalate endpoint which bypasses
|
||||
normal notification permission checks. All agents can escalate.
|
||||
|
||||
Note: _agent_id is unused since the API endpoint uses its own auth context.
|
||||
Keeping for consistent function signature with other handlers.
|
||||
"""
|
||||
try:
|
||||
# Use the dedicated escalate endpoint (bypasses notification permissions)
|
||||
escalate_data = {
|
||||
"reason": input_data.reason,
|
||||
}
|
||||
if input_data.escalate_to:
|
||||
escalate_data["escalate_to"] = input_data.escalate_to
|
||||
|
||||
resp = await client.post(
|
||||
f"/tasks/{input_data.task_id}/escalate", json=escalate_data
|
||||
)
|
||||
|
||||
try:
|
||||
task_resp = await client.get(f"/tasks/{input_data.task_id}")
|
||||
if task_resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||
if resp.is_status(status.HTTP_404_NOT_FOUND):
|
||||
return format_error_response(
|
||||
"NOT_FOUND", f"Task {input_data.task_id} not found"
|
||||
)
|
||||
task = task_resp.json()
|
||||
|
||||
notification = _build_escalation_notification(
|
||||
task, input_data.task_id, agent_id, input_data.reason, target_uuid
|
||||
)
|
||||
notif_resp = await client.post("/notifications", json=notification)
|
||||
|
||||
if not notif_resp.ok and not notif_resp.is_status(status.HTTP_201_CREATED):
|
||||
if not resp.ok:
|
||||
error_detail = resp.text
|
||||
try:
|
||||
error_json = resp.json()
|
||||
error_detail = error_json.get("detail", resp.text)
|
||||
except Exception:
|
||||
pass
|
||||
return format_error_response(
|
||||
"ESCALATION_FAILED",
|
||||
"Failed to send escalation notification",
|
||||
{"status_code": notif_resp.status_code, "detail": notif_resp.text},
|
||||
f"Failed to escalate task: {error_detail}",
|
||||
{"status_code": resp.status_code},
|
||||
)
|
||||
|
||||
result = resp.json()
|
||||
|
||||
# Get task for response formatting
|
||||
task_resp = await client.get(f"/tasks/{input_data.task_id}")
|
||||
task = task_resp.json() if task_resp.ok else {}
|
||||
|
||||
guidance = result.get(
|
||||
"message",
|
||||
f"Task escalated to {result.get('escalated_to', 'PM')}. "
|
||||
"They will be notified and can reassign or provide guidance.",
|
||||
)
|
||||
return format_task_response(task, "ESCALATED", guidance)
|
||||
|
||||
except Exception as e:
|
||||
return format_error_response(
|
||||
"CONNECTION_ERROR",
|
||||
f"Failed to connect to API: {type(e).__name__}",
|
||||
)
|
||||
|
||||
guidance = (
|
||||
f"Task escalated to {target}. Reason: {input_data.reason}. "
|
||||
"They will be notified and can reassign or provide guidance."
|
||||
)
|
||||
return format_task_response(task, "ESCALATED", guidance)
|
||||
|
||||
|
||||
async def handle_task_activate(
|
||||
client: ApiClient, task_id: str, agent_id: str
|
||||
|
||||
@@ -2916,11 +2916,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.0"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/1d/d559954c70be4aade5a6c292c2a940718c4f1da764866b82d8f4261eea3c/pyparsing-3.3.0.tar.gz", hash = "sha256:0de16f2661afbab11fe6645d9472c3b96968d2fffea5b0cc9da88f5be286f039", size = 1550386, upload-time = "2025-12-22T14:49:04.322Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/23/c8dd17cbb1bd6614f306a983e260e31c01f3e8e8cc8954ba68749db6ae82/pyparsing-3.3.0-py3-none-any.whl", hash = "sha256:d15038408e0097d3a01e7e0846731f7f2450c5b6e4a75a52baabd6bbf24585be", size = 121782, upload-time = "2025-12-22T14:49:02.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4249,11 +4249,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-setuptools"
|
||||
version = "80.9.0.20251221"
|
||||
version = "80.9.0.20251223"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/49/cefdde98e1783c09a100f18ade39335e654e8e3364650e200d39069de701/types_setuptools-80.9.0.20251221.tar.gz", hash = "sha256:05da599f5a062bbee3e83d60318576ba23111a768b7a2e46aa11644109c5d17f", size = 42240, upload-time = "2025-12-21T03:20:36.236Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/07/d1b605230730990de20477150191d6dccf6aecc037da94c9960a5d563bc8/types_setuptools-80.9.0.20251223.tar.gz", hash = "sha256:d3411059ae2f5f03985217d86ac6084efea2c9e9cacd5f0869ef950f308169b2", size = 42420, upload-time = "2025-12-23T03:18:26.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/40/999a63965aaf1f67988ddf64a9ba602fde041a4199840d256dd60c7f9fa9/types_setuptools-80.9.0.20251221-py3-none-any.whl", hash = "sha256:fecf4b9ebfc4cdd9cd38b898b653ad197507d7a62d465a168b709c56e94b02c4", size = 64205, upload-time = "2025-12-21T03:20:35.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5c/b8877da94012dbc6643e4eeca22bca9b99b295be05d161f8a403ae9387c0/types_setuptools-80.9.0.20251223-py3-none-any.whl", hash = "sha256:1b36db79d724c2287d83dc052cf887b47c0da6a2fff044378be0b019545f56e6", size = 64318, upload-time = "2025-12-23T03:18:25.868Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user