Files
roboco/roboco/exceptions.py
T
2817ca1ceb Fix the PR-divergence respawn loop: loop gate, CEO god-mode, PR conflict resolver, sequence-ordered merge (#164)
* fix(orchestrator,panel): bound the respawn loop gate and give the CEO a status override

The PM respawn loop gate could never fire on a recurring tracing_gap: every
same-status respawn that emitted a tracing_gap reset the strike counter, so a
task whose unblock can never satisfy its decision gate respawned forever. Cap
the number of tracing_gap resets (pm_respawn_max_tracing_resets) so strikes
accrue once a gap is clearly recurring rather than progressing, and route the
pm-review and blocker dispatch respawn paths through the gate so it actually
applies to those loops.

Panel: the task status dropdown was driven solely by the lifecycle graph, so a
task wedged in a terminal/blocked state offered no actionable transitions. Add
an audited admin status override (PATCH status -> admin_set_status) for every
non-in-band target, letting the human operator force any state.

* feat(git): add rebase_onto_base and close_pull_request PR-divergence primitives

Agents had no way to resolve a PR that could not merge because a sibling merged
overlapping work first: their only moves were complete (which 405s) or block
(which loops). Add the two missing operations:

- rebase_onto_base rebases a head branch onto the latest base and classifies
  the outcome: superseded (no unique commits -> safe to close), rebased (unique
  work -> force-pushed, ready to merge), or conflicts (aborted, needs a human).
- close_pull_request retires a superseded PR with an explanatory comment.

These back both the sequence-ordered merge and the conflict resolver.

* feat(gateway): auto-resolve a leaf PR that can't merge instead of looping

When a sibling lands overlapping work first, the cell PM's complete() merge
hits a GitHub 405 and the task re-blocks, respawning the PM forever (the
production wedge: one task burned 6000+ tool calls over 3 hours). The merge
now raises MergeConflictError, and cell_pm_complete resolves it:

- rebase the branch onto the current base;
- superseded (no unique commits) -> close the dead PR + complete the task
  without a redundant merge (the manual action operators kept requesting);
- rebased (unique work) -> retry the merge, then complete;
- genuine conflicts -> admin-override the task to awaiting_ceo_approval and
  alert the CEO, so it leaves agent dispatch instead of looping.

MergeConflictError subclasses GitError, so existing handlers are unaffected.

* test(git): silence unused-arg lint in close_pull_request stub

* feat(orchestrator): sequence-ordered merge for leaf siblings

Leaf siblings share one cell branch, but within-cell siblings were all left at
the default sequence 0, so two leaf PRs raced into the same branch and the
second wedged. Now:

- decomposition assigns each new sibling the next ordinal within its parent, so
  the merge order is well-defined;
- the pm-review dispatcher holds a higher-sequence leaf until its earlier
  same-team siblings are terminal, so they merge into the shared branch in order
  instead of racing.

Loop-free by construction: a gated task is simply not dispatched this tick (no
reject, no respawn). Terminal siblings never block, so a cancelled sibling can't
deadlock the rest; any sibling lookup failure degrades to dispatch.

* test: use monkeypatch.setattr instead of type:ignore in new tests

CI type-checks tests/ (the type-gated suite) which my local 'mypy roboco/' skipped.
The method-mock assignments tripped mypy method-assign/assignment; replace the
silencing comments with monkeypatch.setattr and local mock refs for assertions,
matching the project's no-type:ignore rule.

* fix(git): stop get_status misreporting an unstaged deletion as staged

git_status used stdout.strip().split() before parsing porcelain. strip() eats
the leading space on the first line, so an unstaged deletion (' D file') became
'D file' and parsed as a STAGED deletion — the false 'staged' that caused 6
wasted QA cycles when a dev deleted a file without staging it. Use splitlines(),
which preserves the index/worktree status columns.

* feat(panel): mobile sidebar hamburger + Sheet drawer (AC1)

The umbrella's AC1 was never built: on mobile the sidebar had no entry point.
Extract the nav/footer into shared SidebarNav/SidebarFooter, hide the static
sidebar below md, and add a hamburger in the header that opens the same nav in a
left Sheet drawer (closing on navigation). Desktop is unchanged.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-14 23:18:58 +02:00

497 lines
14 KiB
Python

"""
RoboCo Custom Exceptions
Structured exception hierarchy for the AI Agents Company system.
All exceptions include context for debugging and logging.
"""
import re
from typing import Any, ClassVar
from uuid import UUID
class RobocoError(Exception):
"""
Base exception for all RoboCo errors.
All exceptions include:
- message: Human-readable error description
- code: Machine-readable error code
- details: Additional context for debugging
"""
def __init__(
self,
message: str,
code: str = "ROBOCO_ERROR",
details: dict[str, Any] | None = None,
):
self.message = message
self.code = code
self.details = details or {}
super().__init__(self.message)
def to_dict(self) -> dict[str, Any]:
"""Convert exception to dictionary for API responses."""
return {
"error": {
"code": self.code,
"message": self.message,
"details": self.details,
}
}
# =============================================================================
# RESOURCE ERRORS
# =============================================================================
class NotFoundError(RobocoError):
"""Resource not found."""
def __init__(
self,
resource_type: str,
resource_id: str | UUID,
details: dict[str, Any] | None = None,
):
self.resource_type = resource_type
self.resource_id = str(resource_id)
super().__init__(
message=f"{resource_type} not found: {resource_id}",
code="NOT_FOUND",
details={
"resource_type": resource_type,
"resource_id": self.resource_id,
**(details or {}),
},
)
# =============================================================================
# VALIDATION ERRORS
# =============================================================================
class ValidationError(RobocoError):
"""Input validation failed."""
def __init__(
self,
message: str,
field: str | None = None,
value: Any = None,
details: dict[str, Any] | None = None,
):
super().__init__(
message=message,
code="VALIDATION_ERROR",
details={
"field": field,
"value": str(value) if value is not None else None,
**(details or {}),
},
)
class InvalidStateError(RobocoError):
"""Operation not allowed in current state."""
def __init__(
self,
current_state: str,
operation: str,
allowed_states: list[str] | None = None,
details: dict[str, Any] | None = None,
):
allowed = (
f" (allowed from: {', '.join(allowed_states)})" if allowed_states else ""
)
super().__init__(
message=f"Cannot {operation} in state '{current_state}'{allowed}",
code="INVALID_STATE",
details={
"current_state": current_state,
"operation": operation,
"allowed_states": allowed_states,
**(details or {}),
},
)
# =============================================================================
# PERMISSION ERRORS
# =============================================================================
class PermissionDeniedError(RobocoError):
"""Agent does not have permission for this action."""
def __init__(
self,
action: str,
resource: str | None = None,
agent_id: str | UUID | None = None,
details: dict[str, Any] | None = None,
):
resource_str = f" on {resource}" if resource else ""
super().__init__(
message=f"Permission denied: {action}{resource_str}",
code="PERMISSION_DENIED",
details={
"action": action,
"resource": resource,
"agent_id": str(agent_id) if agent_id else None,
**(details or {}),
},
)
class AuthenticationError(RobocoError):
"""Authentication failed."""
def __init__(
self,
message: str = "Authentication required",
details: dict[str, Any] | None = None,
):
super().__init__(
message=message,
code="AUTHENTICATION_REQUIRED",
details=details,
)
# =============================================================================
# TASK ERRORS
# =============================================================================
class TaskError(RobocoError):
"""Base class for task-related errors."""
def __init__(
self,
message: str,
task_id: str | UUID | None = None,
code: str = "TASK_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(
message=message,
code=code,
details={
"task_id": str(task_id) if task_id else None,
**(details or {}),
},
)
class TaskLifecycleError(TaskError):
"""Invalid task state transition."""
# Procedural hints for the common "I skipped a step" footguns. Keyed by
# (current_status, target_status); value is the tool-call sequence the
# agent needs to run to actually reach the target. Weak models read
# "valid transitions: [...]" and then guess — giving them the tool
# calls explicitly saves the guess cycle.
_TRANSITION_HINTS: ClassVar[dict[tuple[str, str], str]] = {
("claimed", "awaiting_documentation"): (
"QA pass skipped the in_progress step. "
"Call gateway i_will_work_on(task_id, plan='...') first, "
"then pass(task_id, notes=...)."
),
("claimed", "awaiting_pm_review"): (
"Call i_will_work_on(task_id, plan='...') first to "
"claimed → in_progress, then the handoff verb for your role."
),
("claimed", "completed"): (
"Call i_will_work_on(task_id, plan='...') before complete(task_id)."
),
("claimed", "needs_revision"): (
"QA fail from claimed needs the start step first. "
"Call i_will_work_on(task_id, plan='...') then "
"fail(task_id, issues=[...])."
),
("pending", "in_progress"): (
"Pending tasks must be claimed + planned first. "
"Call gateway i_will_work_on(task_id, plan='...')."
),
("backlog", "in_progress"): (
"Activate the task first: PATCH /api/tasks/{id} "
"(status=pending), then i_will_work_on(task_id, plan='...')."
),
}
def __init__(
self,
current_status: str,
target_status: str,
**kwargs: Any,
):
"""
Initialize a TaskLifecycleError.
Args:
current_status: Current task status
target_status: Target status that was rejected
**kwargs: Optional: task_id, message, valid_transitions, or other details
"""
valid_transitions = kwargs.pop("valid_transitions", None)
message = kwargs.pop("message", None)
task_id = kwargs.pop("task_id", None)
default_msg = f"Cannot transition from '{current_status}' to '{target_status}'"
if valid_transitions:
default_msg += f". Valid transitions: {valid_transitions}"
hint = self._TRANSITION_HINTS.get((current_status, target_status))
if hint:
default_msg += f". {hint}"
super().__init__(
message=message or default_msg,
task_id=task_id,
code="TASK_LIFECYCLE_ERROR",
details={
"current_status": current_status,
"target_status": target_status,
"valid_transitions": valid_transitions,
"hint": hint,
**kwargs,
},
)
self.current_status = current_status
self.target_status = target_status
# =============================================================================
# AGENT ERRORS
# =============================================================================
class AgentError(RobocoError):
"""Base class for agent-related errors."""
def __init__(
self,
message: str,
agent_id: str | UUID | None = None,
code: str = "AGENT_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(
message=message,
code=code,
details={
"agent_id": str(agent_id) if agent_id else None,
**(details or {}),
},
)
# =============================================================================
# CHANNEL/MESSAGING ERRORS
# =============================================================================
class ChannelError(RobocoError):
"""Base class for channel-related errors."""
def __init__(
self,
message: str,
channel_id: str | UUID | None = None,
code: str = "CHANNEL_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(
message=message,
code=code,
details={
"channel_id": str(channel_id) if channel_id else None,
**(details or {}),
},
)
class ChannelAccessDeniedError(ChannelError):
"""Agent does not have access to channel."""
def __init__(
self,
channel_id: str | UUID,
agent_id: str | UUID,
access_type: str = "read",
details: dict[str, Any] | None = None,
):
super().__init__(
message=f"No {access_type} access to channel",
channel_id=channel_id,
code="CHANNEL_ACCESS_DENIED",
details={
"agent_id": str(agent_id),
"access_type": access_type,
**(details or {}),
},
)
class SessionClosedError(RobocoError):
"""Session is closed."""
def __init__(
self,
session_id: str | UUID,
reason: str = "Session has been closed",
details: dict[str, Any] | None = None,
):
super().__init__(
message=reason,
code="SESSION_CLOSED",
details={
"session_id": str(session_id),
**(details or {}),
},
)
# =============================================================================
# NOTIFICATION ERRORS
# =============================================================================
class NotificationError(RobocoError):
"""Base class for notification errors."""
def __init__(
self,
message: str,
code: str = "NOTIFICATION_ERROR",
details: dict[str, Any] | None = None,
):
super().__init__(message=message, code=code, details=details)
# =============================================================================
# SERVICE ERRORS
# =============================================================================
class ServiceError(RobocoError):
"""External service error."""
def __init__(
self,
service: str,
message: str,
details: dict[str, Any] | None = None,
):
super().__init__(
message=f"{service} error: {message}",
code="SERVICE_ERROR",
details={
"service": service,
**(details or {}),
},
)
class DatabaseError(ServiceError):
"""Database operation failed."""
def __init__(
self,
message: str,
operation: str | None = None,
details: dict[str, Any] | None = None,
):
super().__init__(
service="database",
message=message,
details={
"operation": operation,
**(details or {}),
},
)
# =============================================================================
# GIT ERRORS
# =============================================================================
class GitError(ServiceError):
"""Base exception for git operation errors."""
def __init__(
self,
message: str,
details: dict[str, Any] | None = None,
):
super().__init__(
service="git",
message=message,
details=details,
)
def _scrub_git_secrets(text: str) -> str:
"""Redact credentials a git command may echo into stderr.
Push/fetch run with the PAT injected via a URL or an ``http.extraheader``
Basic header; never surface those verbatim in an error message or log.
"""
if not text:
return text
text = re.sub(r"(://)[^/@\s]+@", r"\1***@", text)
text = re.sub(r"(?i)(authorization:\s*basic\s+)\S+", r"\1***", text)
text = re.sub(r"(?i)(extraheader=\S*?basic\s+)\S+", r"\1***", text)
text = re.sub(
r"\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b", "***", text
)
return text
class MergeConflictError(GitError):
"""A PR could not be merged because its branch conflicts with the base.
Raised when the GitHub merge API refuses the merge (e.g. HTTP 405 "not
mergeable") after the in-band retry. Distinct from a generic ``GitError``
so the completion path can route to conflict resolution (rebase / close
superseded / escalate) instead of failing and looping. A subclass of
``GitError`` so existing ``except GitError`` handlers stay correct.
"""
class GitCommandError(GitError):
"""Git command execution failed."""
def __init__(self, command: str, stderr: str) -> None:
scrubbed = _scrub_git_secrets(stderr or "")
# Surface a short, secret-free tail of git's own stderr in the message so
# the real reason (403, non-fast-forward, ...) is visible to callers that
# only render ``.message`` instead of swallowing it as "Command failed".
tail = " ".join(scrubbed.split())[-300:]
message = f"Command failed: {command}"
if tail:
message = f"{message}{tail}"
super().__init__(
message=message,
details={"command": command, "stderr": scrubbed},
)
self.command = command
self.stderr = scrubbed
class GitTimeoutError(GitError):
"""Git command timed out."""
def __init__(self, command: str, timeout: int) -> None:
super().__init__(
message=f"Command timed out after {timeout}s",
details={"command": command, "timeout": timeout},
)
self.command = command
self.timeout = timeout