Fixed escalation bug

This commit is contained in:
Renn F
2025-12-24 22:19:19 +01:00
parent b39461ac85
commit 4055cdead4
5 changed files with 182 additions and 45 deletions
+9 -9
View File
@@ -6,19 +6,19 @@ DEFAULT_PYTHON = 3.10
.PHONY: install .PHONY: install
install: install:
@uv sync @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 # Install dev dependencies
.PHONY: install-dev .PHONY: install-dev
install-dev: install-dev:
@uv sync --extra 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 # Update dependencies
.PHONY: lock .PHONY: lock
lock: lock:
@uv 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 # Upgrade dependencies
@@ -26,7 +26,7 @@ lock:
upgrade: upgrade:
@uv lock --upgrade @uv lock --upgrade
@uv sync --all-extras @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 # INFRASTRUCTURE
@@ -165,7 +165,7 @@ fix:
@echo "Fixing formatting w/ Ruff..." @echo "Fixing formatting w/ Ruff..."
@echo '' @echo ''
@uv run ruff check --fix . @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 # Find dead code with Vulture
.PHONY: vulture .PHONY: vulture
@@ -340,19 +340,19 @@ high-load-stress-test:
.PHONY: serve-docs .PHONY: serve-docs
serve-docs: serve-docs:
@uv run mkdocs serve @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 # Lint documentation
.PHONY: lint-docs .PHONY: lint-docs
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 . @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 # Fix documentation
.PHONY: fix-docs .PHONY: fix-docs
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 . @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 # Prune
.PHONY: prune .PHONY: prune
@@ -362,7 +362,7 @@ prune:
# Clean Cache Files # Clean Cache Files
.PHONY: clean .PHONY: clean
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 # Help
.PHONY: help .PHONY: help
+101 -1
View File
@@ -10,6 +10,7 @@ from uuid import UUID
from fastapi import APIRouter, Body, HTTPException, Query, status from fastapi import APIRouter, Body, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import select
from roboco.agents_config import get_escalation_target
from roboco.api.deps import ( from roboco.api.deps import (
CurrentAgentContext, CurrentAgentContext,
DbSession, DbSession,
@@ -24,6 +25,8 @@ from roboco.api.schemas.tasks import (
CheckpointRequest, CheckpointRequest,
ClaimRequest, ClaimRequest,
CommitRequest, CommitRequest,
EscalateRequest,
EscalateResponse,
ProgressRequest, ProgressRequest,
QANotes, QANotes,
SoftBlockRequest, SoftBlockRequest,
@@ -36,7 +39,7 @@ from roboco.api.schemas.tasks import (
task_to_response, task_to_response,
transform_update_data, 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.base import TaskStatus, Team
from roboco.models.task import TaskCreate from roboco.models.task import TaskCreate
from roboco.services.audit import get_audit_service from roboco.services.audit import get_audit_service
@@ -1013,6 +1016,103 @@ async def cancel_task(
return task_to_response(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 # PROGRESS AND ARTIFACTS
# ============================================================================= # =============================================================================
+24
View File
@@ -313,6 +313,30 @@ class SoftBlockRequest(BaseModel):
what_needed: str = Field(..., description="What is needed to unblock the task") 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): class TaskCountResponse(BaseModel):
"""Task count by category.""" """Task count by category."""
+42 -29
View File
@@ -287,52 +287,65 @@ def _build_escalation_notification(
async def handle_task_escalate( async def handle_task_escalate(
client: ApiClient, input_data: TaskEscalateInput, agent_id: str client: ApiClient, input_data: TaskEscalateInput, _agent_id: str
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle task escalation up the hierarchy.""" """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
target_uuid = await resolve_agent_uuid_cached(target, client) Uses the dedicated /tasks/{task_id}/escalate endpoint which bypasses
if not target_uuid: normal notification permission checks. All agents can escalate.
return format_error_response(
"INVALID_TARGET", Note: _agent_id is unused since the API endpoint uses its own auth context.
f"Could not resolve escalation target: {target}", 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: if resp.is_status(status.HTTP_404_NOT_FOUND):
task_resp = await client.get(f"/tasks/{input_data.task_id}")
if task_resp.is_status(status.HTTP_404_NOT_FOUND):
return format_error_response( return format_error_response(
"NOT_FOUND", f"Task {input_data.task_id} not found" "NOT_FOUND", f"Task {input_data.task_id} not found"
) )
task = task_resp.json()
notification = _build_escalation_notification( if not resp.ok:
task, input_data.task_id, agent_id, input_data.reason, target_uuid error_detail = resp.text
) try:
notif_resp = await client.post("/notifications", json=notification) error_json = resp.json()
error_detail = error_json.get("detail", resp.text)
if not notif_resp.ok and not notif_resp.is_status(status.HTTP_201_CREATED): except Exception:
pass
return format_error_response( return format_error_response(
"ESCALATION_FAILED", "ESCALATION_FAILED",
"Failed to send escalation notification", f"Failed to escalate task: {error_detail}",
{"status_code": notif_resp.status_code, "detail": notif_resp.text}, {"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: except Exception as e:
return format_error_response( return format_error_response(
"CONNECTION_ERROR", "CONNECTION_ERROR",
f"Failed to connect to API: {type(e).__name__}", 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( async def handle_task_activate(
client: ApiClient, task_id: str, agent_id: str client: ApiClient, task_id: str, agent_id: str
Generated
+6 -6
View File
@@ -2916,11 +2916,11 @@ wheels = [
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.3.0" version = "3.3.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -4249,11 +4249,11 @@ wheels = [
[[package]] [[package]]
name = "types-setuptools" name = "types-setuptools"
version = "80.9.0.20251221" version = "80.9.0.20251223"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]