Fix: human surface lifecycle hardening (#81)

* fix: harden agent-idle, redis loop, git errors, escalation audit

- i_am_idle no longer 500s when auto-pausing a task whose commits are
  stored as dicts: tolerate dict-or-object commit refs and run the
  synthetic-checkpoint computation inside the swallowing try block.
- The stream event loop no longer logs an idle redis read-timeout as an
  ERROR every cycle; the blocking-read timeout is treated as a normal idle.
- Git command failures surface git's own (secret-scrubbed) stderr in the
  error message instead of a bare 'Command failed', so push/fetch
  rejections are diagnosable; the injected PAT is redacted.
- The escalate-to-pool redirect emits the task.pending audit event,
  closing a status mutation that previously skipped the audit log.

* fix: let privileged operators set task status via an audited override

The task update route silently dropped a 'status' field in the request body,
so a CEO/admin could not transition a task wedged in a state with no valid
in-band move (e.g. a blocked task whose work merged out-of-band) — the panel
returned 200 while nothing changed. Add 'status' to the update schema and
apply it through a new audited 'admin_set_status' that bypasses the strict
transition validator but always records the audit event. The override
requires elevated permissions; ordinary field updates are unchanged.

* fix: stop human chat sessions from expiring between messages

Messaging sessions fell back to a hardcoded 300s idle timeout, shorter than a
normal pause in a human conversation: the sweeper closed the session and the
next message opened a new one, so a person could not hold a continuous chat.
Make the idle timeout configurable (session_idle_timeout_seconds, default
3600) and resolve an unset timeout to it at every session-creation path
instead of the 300s column fallback.

* fix: resolve doubled doc paths and stop the indexer warning flood

The doc-path resolver returned absolute paths verbatim, so a documenter path
that doubled the base segment (/app/docs/docs/...) never resolved on disk and
the docs never indexed into RAG. Reduce an absolute path under the docs base
to a relative one before normalizing, leaving truly-external absolute paths
for the indexer to skip. The indexer now skips non-markdown source files and
logs a missing/non-doc source at debug instead of warning on every pass.

* fix: reject project repo URLs that point at a protected repository

Add a configurable denylist (protected_git_urls) enforced in the project
create and update paths, so a project cannot be registered against a
repository that must not receive agent commits or merges (e.g. the roboco
source repo during a smoke run). Empty by default (no behavior change);
operators set it to sandbox smoke-test projects.

* fix: let an agent release a blocked task back to the pool

A developer (or QA/doc) trapped on a 'blocked' task had no legal forward
move — every verb rejected from that state — so the dispatcher kept
respawning it with nothing to do. Allow 'unclaim' to release a blocked task
the agent owns back to pending (assignment cleared, work session abandoned,
audited), so the cell PM can re-delegate it instead of the agent churning.

* fix: keep blocked-dev churn out and cell tasks out of board hands

- The dispatcher no longer respawns the owner of a blocked task: from blocked
  the owner has no legal move, so respawning only churns; it is revived on
  unblock or released via unclaim.
- Escalation no longer hands a cell (backend/frontend/ux_ui) coordination task
  to a board/advisory role — such an escalation is diverted to the cell pool,
  matching the existing executable-task guard. main_pm targets are unaffected.

* chore: add an opt-in full clean-slate to the reset script

FULL_RESET=1 wipes everything under the roboco data root except the
persistent service stores (ollama/postgres/redis) and clears the persisted
agent Claude session dirs (ROBOCO_CLAUDE_STATE_DIRS), which otherwise replay
across runs. Default off — the existing DB/Redis wipe + workspace git-reset
is unchanged.

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-08 01:59:51 +02:00
committed by GitHub
co-authored by Renn F
parent 199169ef0e
commit f58fd4530e
22 changed files with 475 additions and 23 deletions
-1
View File
@@ -98,4 +98,3 @@ panel/.env.local
panel/.env.*.local panel/.env.*.local
# Internal-only: strategy/scratch/reference dumps — never publish # Internal-only: strategy/scratch/reference dumps — never publish
docs/internal/ docs/internal/
SMOKE_FINDINGS_*.md
+23
View File
@@ -515,12 +515,35 @@ async def update_task(
# Transform input data for database storage # Transform input data for database storage
updates = transform_update_data(data) updates = transform_update_data(data)
# `status` is not a free-form field — it is an audited admin override so a
# privileged operator can recover a task wedged in a state with no valid
# in-band transition. Pop it out of the generic field update and apply it
# through the audited path, gated on elevated permissions.
new_status = updates.pop("status", None)
task = await service.update(task_id, **updates) task = await service.update(task_id, **updates)
if not task: if not task:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task update failed unexpectedly", detail="Task update failed unexpectedly",
) )
if new_status is not None and new_status != task.status:
if not has_higher_perms:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only privileged roles may override task status.",
)
task = await service.admin_set_status(
task_id,
new_status,
actor_id=agent.agent_id,
actor_role=getattr(agent, "role", None),
)
if not task:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Task status override failed unexpectedly",
)
await db.commit() await db.commit()
return task_to_response(task) return task_to_response(task)
+5
View File
@@ -218,6 +218,11 @@ class TaskUpdate(BaseModel):
auditor_notes: str | None = None auditor_notes: str | None = None
quick_context: str | None = None quick_context: str | None = None
# Lifecycle override — privileged/admin only. Applied by the route as an
# audited force-transition (so an operator can recover a task wedged in a
# state with no valid in-band move), never as a free-form field set.
status: TaskStatus | None = None
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def _reject_explicit_blank_acceptance_criteria(cls, data: Any) -> Any: def _reject_explicit_blank_acceptance_criteria(cls, data: Any) -> Any:
+19
View File
@@ -279,6 +279,25 @@ class Settings(BaseSettings):
), ),
) )
session_idle_timeout_seconds: int = Field(
default=3600,
ge=30,
description=(
"Idle seconds before a messaging session is swept closed. The "
"previous 300s default was shorter than a human conversation pause, "
"so a person's chat session expired and reopened between messages."
),
)
protected_git_urls: list[str] = Field(
default_factory=list,
description=(
"Repo URL substrings a project may not point at (e.g. the roboco "
"source repo). Blocks agent commits/merges from reaching a protected "
"repository; set this to sandbox smoke-test projects."
),
)
# ========================================================================== # ==========================================================================
# Agent Guardrails (per-session budgets, loop detection, SLAs) # Agent Guardrails (per-session budgets, loop detection, SLAs)
# ========================================================================== # ==========================================================================
+7
View File
@@ -15,6 +15,7 @@ from typing import Any
import redis.asyncio as redis import redis.asyncio as redis
import structlog import structlog
from redis.exceptions import ResponseError from redis.exceptions import ResponseError
from redis.exceptions import TimeoutError as RedisTimeoutError
from roboco.config import settings from roboco.config import settings
from roboco.models.events import Event, EventType from roboco.models.events import Event, EventType
@@ -219,6 +220,12 @@ class StreamEventBus:
if await self._handle_response_error(e, streams): if await self._handle_response_error(e, streams):
continue continue
await asyncio.sleep(1) await asyncio.sleep(1)
except (RedisTimeoutError, TimeoutError):
# An idle XREADGROUP(block=...) hits the client read-timeout when
# no new message arrives within the block window. This is the
# normal idle path, not an error — re-block on the next iteration
# without logging or back-off.
continue
except Exception as e: except Exception as e:
logger.error("Error in stream event loop", error=str(e)) logger.error("Error in stream event loop", error=str(e))
await asyncio.sleep(1) await asyncio.sleep(1)
+29 -3
View File
@@ -5,6 +5,7 @@ Structured exception hierarchy for the AI Agents Company system.
All exceptions include context for debugging and logging. All exceptions include context for debugging and logging.
""" """
import re
from typing import Any, ClassVar from typing import Any, ClassVar
from uuid import UUID from uuid import UUID
@@ -435,16 +436,41 @@ class GitError(ServiceError):
) )
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 GitCommandError(GitError): class GitCommandError(GitError):
"""Git command execution failed.""" """Git command execution failed."""
def __init__(self, command: str, stderr: str) -> None: 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__( super().__init__(
message=f"Command failed: {command}", message=message,
details={"command": command, "stderr": stderr}, details={"command": command, "stderr": scrubbed},
) )
self.command = command self.command = command
self.stderr = stderr self.stderr = scrubbed
class GitTimeoutError(GitError): class GitTimeoutError(GitError):
+2 -2
View File
@@ -118,7 +118,7 @@ OLLAMA_ROLE_DEFAULTS: dict[str, str] = {
} }
# The Ollama model picked for "pure Ollama" mode's GLOBAL row when the # The Ollama model picked for "pure Ollama" mode's GLOBAL row when the
# caller doesn't override. Kimi K2.6 wins as the generalist because it has # caller doesn't override. Minimax M3 wins as the generalist because it has
# the strongest reasoning/tool-use profile and can fall back to coding/writing # the strongest reasoning/tool-use profile and can fall back to coding/writing
# adequately if a role ends up mapped to the global default. # adequately if a role ends up mapped to the global default.
OLLAMA_DEFAULT_MODEL: str = "kimi-k2.6:cloud" OLLAMA_DEFAULT_MODEL: str = "minimax-m3:cloud"
+7 -2
View File
@@ -4808,11 +4808,16 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
async def _handle_dev_existing_owner( async def _handle_dev_existing_owner(
self, task: dict[str, Any], status: str, agent_slug: str self, task: dict[str, Any], status: str, agent_slug: str
) -> None: ) -> None:
"""Respawn existing dev for needs_revision / in_progress / claimed / blocked.""" """Respawn existing dev for needs_revision / in_progress / claimed."""
# A `blocked` task is waiting for its blocker to clear (PM / dependency);
# the owner has no legal move from `blocked`, so respawning it does
# nothing but churn. It is revived only when unblocked back to
# in_progress, or released to the pool (unclaim) for re-delegation.
if status == "blocked":
return
if status in ( if status in (
"in_progress", "in_progress",
"claimed", "claimed",
"blocked",
) and not self._is_agent_active(agent_slug): ) and not self._is_agent_active(agent_slug):
logger.info( logger.info(
"Respawning agent for orphaned task", "Respawning agent for orphaned task",
+11 -4
View File
@@ -2707,11 +2707,18 @@ class Choreographer:
Failure is logged and swallowed the pause already happened and the Failure is logged and swallowed the pause already happened and the
caller must not be affected by a checkpoint DB error. caller must not be affected by a checkpoint DB error.
""" """
commit_refs = [c.sha for c in (task.commits or [])[-3:]]
commit_count = len(task.commits or [])
state_summary = f"auto-paused on i_am_idle (commits: {commit_count})"
remaining_work = commit_refs if commit_refs else ["no commits yet"]
try: try:
commits = task.commits or []
# commits may be hydrated as CommitRef objects or as plain dicts
# (JSON column round-trip); tolerate both rather than assuming `.sha`.
commit_refs = [
c.get("sha") if isinstance(c, dict) else getattr(c, "sha", None)
for c in commits[-3:]
]
commit_refs = [ref for ref in commit_refs if ref]
commit_count = len(commits)
state_summary = f"auto-paused on i_am_idle (commits: {commit_count})"
remaining_work = commit_refs if commit_refs else ["no commits yet"]
await self.task.add_checkpoint( await self.task.add_checkpoint(
task_id=task.id, task_id=task.id,
agent_id=agent_id, agent_id=agent_id,
+21 -3
View File
@@ -20,6 +20,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from roboco.config import settings
from roboco.db.tables import ( from roboco.db.tables import (
ChannelTable, ChannelTable,
GroupTable, GroupTable,
@@ -378,6 +379,19 @@ class MessagingService(BaseService):
# SESSION OPERATIONS (TASK-015) # SESSION OPERATIONS (TASK-015)
# ========================================================================= # =========================================================================
@staticmethod
def _resolve_session_timeout(requested: int | None) -> int:
"""Resolve a session's idle-timeout, defaulting to the configurable value.
An unset timeout previously fell through to the column default of 300s,
which is shorter than a human conversation pause the session was swept
between messages and a new one opened on the next post. Resolve it to
``session_idle_timeout_seconds`` so human-paced chats stay on one session.
"""
if requested is not None:
return requested
return settings.session_idle_timeout_seconds
async def create_session(self, req: SessionCreateRequest) -> SessionTable: async def create_session(self, req: SessionCreateRequest) -> SessionTable:
""" """
Create a new session in a group. Create a new session in a group.
@@ -404,7 +418,7 @@ class MessagingService(BaseService):
group_id=req.group_id, group_id=req.group_id,
max_message_count=req.max_message_count, max_message_count=req.max_message_count,
max_content_length=req.max_content_length, max_content_length=req.max_content_length,
timeout_seconds=req.timeout_seconds, timeout_seconds=self._resolve_session_timeout(req.timeout_seconds),
status=SessionStatus.ACTIVE, status=SessionStatus.ACTIVE,
scope=req.scope, scope=req.scope,
) )
@@ -643,7 +657,7 @@ class MessagingService(BaseService):
max_time_window=(_minutes_to_timedelta(request.max_time_window_minutes)), max_time_window=(_minutes_to_timedelta(request.max_time_window_minutes)),
max_message_count=request.max_message_count, max_message_count=request.max_message_count,
max_content_length=request.max_content_length, max_content_length=request.max_content_length,
timeout_seconds=request.timeout_seconds, timeout_seconds=self._resolve_session_timeout(request.timeout_seconds),
status=SessionStatus.ACTIVE, status=SessionStatus.ACTIVE,
) )
self.session.add(new_session) self.session.add(new_session)
@@ -1141,7 +1155,11 @@ class MessagingService(BaseService):
group_id=cast("UUID", group.id), group_id=cast("UUID", group.id),
max_message_count=(req.config.max_message_count if req.config else None), max_message_count=(req.config.max_message_count if req.config else None),
max_content_length=(req.config.max_content_length if req.config else None), max_content_length=(req.config.max_content_length if req.config else None),
timeout_seconds=(req.config.timeout_seconds if req.config else 300), timeout_seconds=(
req.config.timeout_seconds
if req.config and req.config.timeout_seconds is not None
else settings.session_idle_timeout_seconds
),
scope=req.scope, scope=req.scope,
) )
+10 -2
View File
@@ -85,8 +85,16 @@ class DocsIndexPlugin(BaseIndexPlugin):
] ]
return md_files + txt_files return md_files + txt_files
if source_path.exists(): if source_path.exists():
return [source_path] # Only markdown/text files are docs; a recorded path to a source
logger.warning(f"Source not found: {source}") # file (e.g. a .tsx) is not indexable and is skipped quietly.
if source_path.suffix.lower() in {".md", ".txt"}:
return [source_path]
logger.debug(f"Skipping non-doc source path: {source}")
return []
# A documenter may record a path for a doc that was not written under
# the docs root; this is not actionable at index time, so log at debug
# instead of flooding a warning on every pass.
logger.debug(f"Doc source not found, skipping: {source}")
return [] return []
def _read_file_record( def _read_file_record(
+22
View File
@@ -11,7 +11,9 @@ from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from roboco.config import settings
from roboco.db.tables import ProjectTable from roboco.db.tables import ProjectTable
from roboco.exceptions import ValidationError
from roboco.models.base import Team from roboco.models.base import Team
from roboco.models.project import ProjectCreate, ProjectUpdate from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError from roboco.services.base import BaseService, ConflictError, NotFoundError
@@ -35,6 +37,22 @@ class ProjectService(BaseService):
# CRUD OPERATIONS # CRUD OPERATIONS
# ========================================================================= # =========================================================================
def _assert_git_url_allowed(self, git_url: str | None) -> None:
"""Reject a project repo URL that matches a protected (denylisted) repo.
Keeps agent commits/merges out of a repository that must not receive
them e.g. the roboco source repo during a smoke run.
"""
if not git_url:
return
for protected in settings.protected_git_urls:
if protected and protected in git_url:
raise ValidationError(
"Project git_url may not point at a protected repository "
f"('{protected}').",
field="git_url",
)
async def create( async def create(
self, self,
data: ProjectCreate, data: ProjectCreate,
@@ -61,6 +79,8 @@ class ProjectService(BaseService):
resource_type="project", resource_type="project",
) )
self._assert_git_url_allowed(data.git_url)
# Encrypt git token if provided # Encrypt git token if provided
encrypted_token = None encrypted_token = None
if data.git_token: if data.git_token:
@@ -141,6 +161,8 @@ class ProjectService(BaseService):
if not project: if not project:
return None return None
self._assert_git_url_allowed(data.git_url)
# Handle git_token specially (empty string clears, None leaves unchanged) # Handle git_token specially (empty string clears, None leaves unchanged)
token_updated = False token_updated = False
if data.git_token is not None: if data.git_token is not None:
+119 -4
View File
@@ -87,6 +87,12 @@ _DESCENDANT_EXECUTABLE_TASK_TYPES: frozenset[str] = frozenset(
{TaskType.CODE.value, TaskType.DOCUMENTATION.value, TaskType.DESIGN.value} {TaskType.CODE.value, TaskType.DOCUMENTATION.value, TaskType.DESIGN.value}
) )
# Implementation-cell teams. A board/advisory role must never own a cell task —
# including the cell's own coordination/planning task (which carries a cell team
# but not a CODE/DOC/DESIGN type), so escalating one toward a board role is
# diverted to the cell pool instead of handing ownership up.
_CELL_TEAMS: frozenset[str] = frozenset({"backend", "frontend", "ux_ui"})
def _is_descendant_executable_task(task: TaskTable) -> bool: def _is_descendant_executable_task(task: TaskTable) -> bool:
"""True for a child task that does cell-executed work (#14 guard). """True for a child task that does cell-executed work (#14 guard).
@@ -108,6 +114,19 @@ def _is_descendant_executable_task(task: TaskTable) -> bool:
return str(type_value) in _DESCENDANT_EXECUTABLE_TASK_TYPES return str(type_value) in _DESCENDANT_EXECUTABLE_TASK_TYPES
def _is_cell_team_task(task: TaskTable) -> bool:
"""True for a descendant task owned by an implementation cell.
Complements ``_is_descendant_executable_task``: a cell's coordination /
planning task carries a cell ``team`` but not a CODE/DOC/DESIGN ``task_type``,
so the executable-type check alone would let it escalate onto a board role.
"""
if task.parent_task_id is None:
return False
team_value = getattr(task.team, "value", task.team)
return str(team_value) in _CELL_TEAMS
# Notes fields (dev_notes, qa_notes, quick_context) are append-only — # Notes fields (dev_notes, qa_notes, quick_context) are append-only —
# every revision cycle adds more. Cap total size so a task that cycles # every revision cycle adds more. Cap total size so a task that cycles
# dozens of times doesn't grow into megabytes. When we exceed the cap, # dozens of times doesn't grow into megabytes. When we exceed the cap,
@@ -974,6 +993,47 @@ class TaskService(BaseService):
) )
return task return task
async def admin_set_status(
self,
task_id: UUID,
new_status: TaskStatus,
*,
actor_id: str | UUID | None = None,
actor_role: str | None = None,
) -> TaskTable | None:
"""Privileged override: set a task's status directly, always audited.
Bypasses the strict transition validator so an operator can recover a
task wedged in a state with no valid in-band move (e.g. a ``blocked``
task whose work already merged out-of-band). The change is recorded in
the audit log like any other transition no status change may skip it.
"""
task = await self.get(task_id)
if not task:
return None
from_status = (
task.status.value
if isinstance(task.status, TaskStatus)
else str(task.status)
)
task.status = new_status
await self.session.flush()
self._emit_status_transition_audit(
task,
from_status=from_status,
to_status=new_status.value,
agent_role=actor_role,
audit_agent_id=actor_id,
)
self.log.info(
"Task status set via admin override",
task_id=str(task_id),
from_status=from_status,
to_status=new_status.value,
actor=str(actor_id) if actor_id else None,
)
return task
async def delete(self, task_id: UUID) -> bool: async def delete(self, task_id: UUID) -> bool:
"""Delete a task and all its descendants.""" """Delete a task and all its descendants."""
task = await self.get(task_id) task = await self.get(task_id)
@@ -1735,8 +1795,16 @@ class TaskService(BaseService):
from roboco.services.docs import DOCS_BASE_PATH from roboco.services.docs import DOCS_BASE_PATH
path = Path(rel_path) path = Path(rel_path)
# An absolute path already rooted at the docs base may double the
# segment (``/app/docs/docs/...``) or simply be re-anchored here; reduce
# it to a path relative to the base so the normalization below applies
# uniformly. An absolute path OUTSIDE the docs root (e.g. a workspace
# source file) is returned as-is for the indexer to skip.
if path.is_absolute(): if path.is_absolute():
return str(path) try:
path = path.relative_to(DOCS_BASE_PATH)
except ValueError:
return str(path)
parts = path.parts parts = path.parts
if parts and parts[0] == DOCS_BASE_PATH.name: if parts and parts[0] == DOCS_BASE_PATH.name:
path = Path(*parts[1:]) if len(parts) > 1 else Path() path = Path(*parts[1:]) if len(parts) > 1 else Path()
@@ -2238,6 +2306,37 @@ class TaskService(BaseService):
task.active_claimant_id = cast("Any", None) task.active_claimant_id = cast("Any", None)
await self.session.flush() await self.session.flush()
return task return task
# A task the agent owns but cannot advance — it is `blocked` (a blocker
# it cannot self-resolve, or a dependency block) — is otherwise a trap:
# from `blocked` the agent has no legal forward verb and the dispatcher
# keeps respawning it. Releasing the claim returns the task to the pool
# for the cell PM to re-delegate. Audited; the active WorkSession is
# abandoned so a re-claim does not trip the uniqueness constraint.
if task.status == TaskStatus.BLOCKED:
pre_status = (
task.status.value
if isinstance(task.status, TaskStatus)
else str(task.status)
)
prior_owner = cast("Any", task.claimed_by or task.assigned_to)
if task.work_session_id:
await self._abandon_work_session_best_effort(
task.work_session_id, reason="agent-unclaim-from-blocked"
)
task.work_session_id = cast("Any", None)
task.status = TaskStatus.PENDING
task.assigned_to = cast("Any", None)
task.claimed_by = cast("Any", None)
task.active_claimant_id = cast("Any", None)
await self.session.flush()
self._emit_status_transition_audit(
task,
from_status=pre_status,
to_status=TaskStatus.PENDING.value,
agent_role=None,
audit_agent_id=prior_owner,
)
return task
if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS): if task.status not in (TaskStatus.CLAIMED, TaskStatus.IN_PROGRESS):
return None return None
@@ -3416,9 +3515,9 @@ class TaskService(BaseService):
primitive so both the gateway ``escalate`` verb and the HTTP escalate primitive so both the gateway ``escalate`` verb and the HTTP escalate
route are covered. route are covered.
""" """
if _is_descendant_executable_task(task) and await self._is_board_advisory_agent( if (
target_agent_id _is_descendant_executable_task(task) or _is_cell_team_task(task)
): ) and await self._is_board_advisory_agent(target_agent_id):
await self._release_code_task_to_pool( await self._release_code_task_to_pool(
task=task, task=task,
escalator_slug=escalator_slug, escalator_slug=escalator_slug,
@@ -5656,6 +5755,12 @@ class TaskService(BaseService):
re-dispatch source), and appends an audit note explaining why the board re-dispatch source), and appends an audit note explaining why the board
hand-off was refused. hand-off was refused.
""" """
pre_status = (
task.status.value
if isinstance(task.status, TaskStatus)
else str(task.status)
)
prior_owner = cast("Any", task.claimed_by or task.assigned_to)
task.assigned_to = cast("Any", None) task.assigned_to = cast("Any", None)
task.claimed_by = cast("Any", None) task.claimed_by = cast("Any", None)
task.active_claimant_id = cast("Any", None) task.active_claimant_id = cast("Any", None)
@@ -5670,6 +5775,16 @@ class TaskService(BaseService):
) )
task.dev_notes = existing_notes + note task.dev_notes = existing_notes + note
await self.session.flush() await self.session.flush()
# This path sets PENDING directly (bypassing the strict transition
# validator), so emit the task.pending audit explicitly — no status
# change may skip the audit log.
self._emit_status_transition_audit(
task,
from_status=pre_status,
to_status=TaskStatus.PENDING.value,
agent_role=None,
audit_agent_id=prior_owner,
)
self.log.info( self.log.info(
"Descendant executable task released to pool instead of board escalation", "Descendant executable task released to pool instead of board escalation",
task_id=str(task.id), task_id=str(task.id),
+49
View File
@@ -22,6 +22,11 @@
# if none exist). # if none exist).
# SKIP_WORKSPACE_RESET=1 — skip the workspace cleanup step entirely # SKIP_WORKSPACE_RESET=1 — skip the workspace cleanup step entirely
# (DB + Redis only). # (DB + Redis only).
# FULL_RESET=1 — opt-in aggressive clean-slate: after the DB + Redis wipe,
# delete everything under the roboco data root EXCEPT ollama/postgres/redis
# (overridable via ROBOCO_DATA_ROOT) and clear the persisted agent Claude
# session dirs listed in ROBOCO_CLAUDE_STATE_DIRS (space-separated). Skips
# the per-workspace git reset (the clones are removed and re-cloned).
set -euo pipefail set -euo pipefail
@@ -71,6 +76,50 @@ if $DOCKER ps --format '{{.Names}}' | grep -q '^roboco-redis$'; then
$DOCKER exec roboco-redis redis-cli FLUSHDB | sed 's/^/ /' $DOCKER exec roboco-redis redis-cli FLUSHDB | sed 's/^/ /'
fi fi
# Optional full clean-slate (opt-in via FULL_RESET=1): wipe everything under the
# roboco data root EXCEPT the persistent service stores (ollama / postgres /
# redis), and clear any persisted agent Claude session state that would
# otherwise replay across runs. Default OFF — the workspace git-reset below is
# the usual path. The data root is resolved from the workspaces root's parent
# (or ROBOCO_DATA_ROOT); the Claude-state paths come from ROBOCO_CLAUDE_STATE_DIRS
# (space-separated) so nothing is guessed.
if [ "${FULL_RESET:-0}" = "1" ]; then
DATA_ROOT="${ROBOCO_DATA_ROOT:-}"
if [ -z "$DATA_ROOT" ]; then
for candidate in /volume1/roboco/data /data; do
if [ -d "$candidate/workspaces" ]; then
DATA_ROOT="$candidate"
break
fi
done
fi
if [ -n "$DATA_ROOT" ] && [ -d "$DATA_ROOT" ]; then
echo ">>> FULL_RESET: wiping $DATA_ROOT/* except ollama/postgres/redis ..."
for entry in "$DATA_ROOT"/*; do
[ -e "$entry" ] || continue
case "$(basename "$entry")" in
ollama | postgres | redis)
echo " keep $(basename "$entry")"
;;
*)
echo " wipe $(basename "$entry")"
rm -rf "$entry"
;;
esac
done
else
echo ">>> FULL_RESET: no data root resolved — skipping data wipe."
fi
for cdir in ${ROBOCO_CLAUDE_STATE_DIRS:-}; do
if [ -e "$cdir" ]; then
echo " clearing Claude session state $cdir"
rm -rf "$cdir"
fi
done
echo ">>> FULL_RESET done."
exit 0
fi
# Workspace reset — each agent has a private git clone at # Workspace reset — each agent has a private git clone at
# {root}/{project}/{team}/{agent}. Leftover staged/untracked edits and # {root}/{project}/{team}/{agent}. Leftover staged/untracked edits and
# feature branches from a previous run will fail the claim→start # feature branches from a previous run will fail the claim→start
@@ -11,6 +11,7 @@ from uuid import uuid4 as _u
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from roboco.config import settings
from roboco.db.tables import AgentTable, MessageTable, ProjectTable, TaskTable from roboco.db.tables import AgentTable, MessageTable, ProjectTable, TaskTable
from roboco.db.tables import AgentTable as _AgentTable from roboco.db.tables import AgentTable as _AgentTable
from roboco.enforcement.channel_access import ChannelAccessDeniedError from roboco.enforcement.channel_access import ChannelAccessDeniedError
@@ -2705,3 +2706,14 @@ async def test_post_to_channel_permitted_agent_succeeds(
) )
assert msg.content == "hello cell" assert msg.content == "hello cell"
assert msg.task_id == task.id assert msg.task_id == task.id
def test_resolve_session_timeout_uses_configurable_default() -> None:
"""An unset session timeout resolves to the configurable default instead of
the old 300s that swept human chats between messages."""
explicit = settings.session_idle_timeout_seconds + 60
assert MessagingService._resolve_session_timeout(explicit) == explicit
assert (
MessagingService._resolve_session_timeout(None)
== settings.session_idle_timeout_seconds
)
+15
View File
@@ -12,7 +12,9 @@ from uuid import uuid4
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from roboco.config import settings
from roboco.db.tables import AgentTable from roboco.db.tables import AgentTable
from roboco.exceptions import ValidationError
from roboco.models import AgentRole, AgentStatus, Team from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.project import ProjectCreate, ProjectUpdate from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import ConflictError, NotFoundError from roboco.services.base import ConflictError, NotFoundError
@@ -89,6 +91,19 @@ async def test_create_project_duplicate_slug_raises(project_setup: dict) -> None
await svc.create(payload, project_setup["creator_id"]) await svc.create(payload, project_setup["creator_id"])
@pytest.mark.asyncio
async def test_create_project_rejects_protected_git_url(
project_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A project may not point at a denylisted repo (keeps agent merges out of it)."""
monkeypatch.setattr(settings, "protected_git_urls", ["github.com/owner/roboco"])
svc = project_setup["svc"]
payload_dict = _project_payload(uuid4().hex[:6]).model_dump()
payload_dict["git_url"] = "https://github.com/owner/roboco.git"
with pytest.raises(ValidationError):
await svc.create(ProjectCreate(**payload_dict), project_setup["creator_id"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_returns_project(project_setup: dict) -> None: async def test_get_returns_project(project_setup: dict) -> None:
svc = project_setup["svc"] svc = project_setup["svc"]
@@ -1246,6 +1246,24 @@ async def test_unclaim_for_agent_releases_claim(
assert result.status == TaskStatus.PENDING assert result.status == TaskStatus.PENDING
@pytest.mark.asyncio
async def test_unclaim_for_agent_releases_blocked_to_pool(
task_setup: dict, db_session: AsyncSession
) -> None:
"""An agent trapped on a `blocked` task can release it back to the pool
(returns to pending, assignment cleared) instead of churning with no move."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.BLOCKED
task.assigned_to = task_setup["agent_id"]
task.claimed_by = task_setup["agent_id"]
await db_session.flush()
result = await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"])
assert result is not None
assert result.status == TaskStatus.PENDING
assert result.assigned_to is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unclaim_for_reaper_resets( async def test_unclaim_for_reaper_resets(
task_setup: dict, db_session: AsyncSession task_setup: dict, db_session: AsyncSession
+17
View File
@@ -264,6 +264,23 @@ async def test_update_task(task_client: dict) -> None:
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY) assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
@pytest.mark.asyncio
async def test_update_task_status_override_recovers_blocked(task_client: dict) -> None:
"""A privileged PATCH with ``status`` is applied as an audited override, so an
operator can recover a task wedged in ``blocked`` (which ``/complete`` refuses)
instead of the status being silently dropped."""
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.BLOCKED)
await task_client["db"].flush()
response = await client.patch(
f"/api/tasks/{task.id}",
json={"status": "completed"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "completed"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_task(task_client: dict) -> None: async def test_delete_task(task_client: dict) -> None:
client = task_client["client"] client = task_client["client"]
@@ -293,3 +293,24 @@ async def test_dispatch_claimed_without_agent_releases_unknown_without_spending_
expected_releases = 2 # both ghost claims released expected_releases = 2 # both ghost claims released
assert release.await_count == expected_releases assert release.await_count == expected_releases
spawn.assert_awaited_once() # then one known assignee respawned spawn.assert_awaited_once() # then one known assignee respawned
@pytest.mark.asyncio
async def test_handle_dev_existing_owner_skips_blocked() -> None:
"""A blocked task's owner is not respawned — it has no legal move from
blocked, so respawning it only churns; it waits for unblock or release."""
orch = _orch()
orch._respawn_dev_if_inactive = AsyncMock()
orch._is_agent_active = MagicMock(return_value=False)
await orch._handle_dev_existing_owner({"id": "t1"}, "blocked", "be-dev-1")
orch._respawn_dev_if_inactive.assert_not_called()
@pytest.mark.asyncio
async def test_handle_dev_existing_owner_respawns_in_progress() -> None:
"""An in_progress task whose owner is inactive is still respawned."""
orch = _orch()
orch._respawn_dev_if_inactive = AsyncMock()
orch._is_agent_active = MagicMock(return_value=False)
await orch._handle_dev_existing_owner({"id": "t1"}, "in_progress", "be-dev-1")
orch._respawn_dev_if_inactive.assert_awaited_once()
@@ -18,7 +18,11 @@ from uuid import uuid4
import pytest import pytest
from roboco.models.base import AgentRole, TaskStatus, TaskType, Team from roboco.models.base import AgentRole, TaskStatus, TaskType, Team
from roboco.services.task import TaskService, _is_descendant_executable_task from roboco.services.task import (
TaskService,
_is_cell_team_task,
_is_descendant_executable_task,
)
def _bind(svc: TaskService, name: str, value: object) -> None: def _bind(svc: TaskService, name: str, value: object) -> None:
@@ -41,6 +45,26 @@ def test_descendant_code_task_is_flagged() -> None:
assert _is_descendant_executable_task(task) is True assert _is_descendant_executable_task(task) is True
def test_descendant_cell_team_task_is_flagged() -> None:
# A cell's own coordination task carries a cell team but a non-executable
# type; it must still not be handed to a board role on escalation.
task = MagicMock(
parent_task_id=uuid4(), team=Team.FRONTEND, task_type=TaskType.PLANNING
)
assert _is_cell_team_task(task) is True
def test_root_cell_team_task_is_not_flagged() -> None:
# A root task can legitimately escalate up the chain (the CEO reviews it).
task = MagicMock(parent_task_id=None, team=Team.FRONTEND)
assert _is_cell_team_task(task) is False
def test_non_cell_team_task_is_not_flagged() -> None:
task = MagicMock(parent_task_id=uuid4(), team=Team.BOARD)
assert _is_cell_team_task(task) is False
def test_descendant_documentation_task_is_flagged() -> None: def test_descendant_documentation_task_is_flagged() -> None:
# #14 broaden: documentation is cell-executed (documenter), not board work. # #14 broaden: documentation is cell-executed (documenter), not board work.
task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.DOCUMENTATION) task = MagicMock(parent_task_id=uuid4(), task_type=TaskType.DOCUMENTATION)
+20 -1
View File
@@ -706,8 +706,27 @@ def test_resolve_doc_abspath_keeps_plain_relative_path() -> None:
def test_resolve_doc_abspath_passes_absolute_path_through() -> None: def test_resolve_doc_abspath_passes_absolute_path_through() -> None:
"""An already-absolute path is trusted as-is (no re-rooting).""" """An absolute path already correctly rooted under the base is unchanged."""
assert ( assert (
TaskService._resolve_doc_abspath("/app/docs/design/spec.md") TaskService._resolve_doc_abspath("/app/docs/design/spec.md")
== "/app/docs/design/spec.md" == "/app/docs/design/spec.md"
) )
def test_resolve_doc_abspath_collapses_doubled_absolute_docs() -> None:
"""An absolute path that doubled the base segment is collapsed.
The documenter sometimes records `/app/docs/docs/...`; previously it was
returned verbatim and never resolved on disk (the recurring "Source not
found" warning).
"""
assert (
TaskService._resolve_doc_abspath("/app/docs/docs/backend/api/prompter.md")
== "/app/docs/backend/api/prompter.md"
)
def test_resolve_doc_abspath_leaves_external_absolute_path() -> None:
"""An absolute path outside the docs root is left as-is for the indexer to skip."""
external = "/data/workspaces/panel/frontend/fe-dev-1/src/page.tsx"
assert TaskService._resolve_doc_abspath(external) == external
+23
View File
@@ -294,6 +294,29 @@ def test_git_command_error() -> None:
assert err.details["command"] == "git push" assert err.details["command"] == "git push"
def test_git_command_error_surfaces_stderr_in_message() -> None:
err = GitCommandError(
command="push -u origin feature/x",
stderr="remote: Permission to owner/repo.git denied.\nfatal: unable to access",
)
# The real reason must reach callers that only render ``.message``.
assert "Command failed: push -u origin feature/x" in err.message
assert "Permission to owner/repo.git denied" in err.message
def test_git_command_error_scrubs_credentials() -> None:
leaky = (
"fatal: unable to access "
"'https://x-access-token:ghp_AbC123456789012345678901234567890@github.com/o/r.git'"
)
err = GitCommandError(command="push", stderr=leaky)
# The injected PAT must never survive into the message, stderr, or details.
assert "ghp_" not in err.message
assert "ghp_" not in err.stderr
assert "ghp_" not in err.details["stderr"]
assert "https://***@github.com" in err.stderr
def test_git_timeout_error() -> None: def test_git_timeout_error() -> None:
err = GitTimeoutError(command="git fetch", timeout=_TIMEOUT_SECONDS) err = GitTimeoutError(command="git fetch", timeout=_TIMEOUT_SECONDS)
assert err.command == "git fetch" assert err.command == "git fetch"