mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Merge 'master' into 'dogfood feature branch' (smoke test run) (#82)
* 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> * fix: read the real commit key (hash) and audit the restore-unblock path - The auto-pause checkpoint and _extract_first_commit_sha read commit dicts by key 'sha', but persisted commits are keyed 'hash' (CommitRef.hash) — the prior change stopped the crash but silently dropped every ref. Read 'hash' (sha fallback) at both sites; the test now uses the production dict shape so the regression can't hide. - unblock_with_restore set status directly and skipped the audit log; emit the status-transition audit there too, like the other direct-set paths. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,7 @@ from uuid import uuid4 as _u
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, MessageTable, ProjectTable, TaskTable
|
||||
from roboco.db.tables import AgentTable as _AgentTable
|
||||
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.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
|
||||
)
|
||||
|
||||
@@ -12,7 +12,9 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.exceptions import ValidationError
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.project import ProjectCreate, ProjectUpdate
|
||||
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"])
|
||||
|
||||
|
||||
@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
|
||||
async def test_get_returns_project(project_setup: dict) -> None:
|
||||
svc = project_setup["svc"]
|
||||
|
||||
@@ -1246,6 +1246,24 @@ async def test_unclaim_for_agent_releases_claim(
|
||||
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
|
||||
async def test_unclaim_for_reaper_resets(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
|
||||
@@ -264,6 +264,23 @@ async def test_update_task(task_client: dict) -> None:
|
||||
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
|
||||
async def test_delete_task(task_client: dict) -> None:
|
||||
client = task_client["client"]
|
||||
|
||||
Reference in New Issue
Block a user