Files
roboco/tests/unit/gateway/test_auto_pause_checkpoint.py
T
306de1e656 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>
2026-06-08 03:27:45 +02:00

225 lines
7.6 KiB
Python

"""Wave C7 (2026-05-12): auto-pause on i_am_idle writes a synthetic checkpoint.
Smoke run 3 showed agents auto-pausing on i_am_idle (correct behavior for
non-terminal tasks) but capturing no checkpoint — panel's Checkpoints column
stayed empty. Pre-gateway parity: the auto-pause path now writes a synthetic
checkpoint summarizing state at pause-time so the panel reflects reality.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
# C8: default-fresh journal:decision so PM-decision gate passes.
# Tests that exercise the gate boundary stub their own value.
# The check matches MagicMock and AsyncMock (the two default sentinel
# types pytest's unittest.mock leaves on un-stubbed return_values).
_ldef = base["journal"].latest_decision_at.return_value
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
return ChoreographerDeps(**base)
@pytest.mark.asyncio
async def test_i_am_idle_with_in_progress_task_writes_checkpoint() -> None:
"""When i_am_idle auto-pauses an in_progress task, a synthetic checkpoint is
written with the correct task_id, agent_id, and a summary mentioning auto-pause.
"""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock()
task_obj.id = task_id
task_obj.status = "in_progress"
task_obj.assigned_to = agent_id
task_obj.commits = []
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_obj]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "idle"
task_svc.add_checkpoint.assert_awaited_once()
call_kwargs = task_svc.add_checkpoint.await_args
assert call_kwargs is not None
# task_id and agent_id must be present
assert call_kwargs.kwargs.get("task_id") == task_id or (
len(call_kwargs.args) >= 1 and call_kwargs.args[0] == task_id
)
second_arg_index = 1
assert call_kwargs.kwargs.get("agent_id") == agent_id or (
len(call_kwargs.args) > second_arg_index
and call_kwargs.args[second_arg_index] == agent_id
)
# Summary must mention auto-pause
state_summary = call_kwargs.kwargs.get("state_summary", "")
assert "auto-pause" in state_summary or "auto_pause" in state_summary
@pytest.mark.asyncio
async def test_i_am_idle_multiple_in_progress_tasks_each_get_checkpoint() -> None:
"""Each auto-paused task gets its own synthetic checkpoint."""
agent_id = uuid4()
task_id_1 = uuid4()
task_id_2 = uuid4()
commit_a = MagicMock()
commit_a.sha = "aaa111"
commit_b = MagicMock()
commit_b.sha = "bbb222"
task_1 = MagicMock()
task_1.id = task_id_1
task_1.status = "in_progress"
task_1.commits = [commit_a, commit_b]
task_2 = MagicMock()
task_2.id = task_id_2
task_2.status = "in_progress"
task_2.commits = []
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_1, task_2]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c.i_am_idle(agent_id)
expected_checkpoint_count = 2
assert task_svc.add_checkpoint.await_count == expected_checkpoint_count
called_task_ids = {
kw.kwargs.get("task_id") or kw.args[0]
for kw in task_svc.add_checkpoint.await_args_list
}
assert task_id_1 in called_task_ids
assert task_id_2 in called_task_ids
@pytest.mark.asyncio
async def test_i_am_idle_with_commits_includes_last_three_in_remaining_work() -> None:
"""Checkpoint's remaining_work contains refs for the last 3 commits."""
agent_id = uuid4()
task_id = uuid4()
# Production shape: task.commits is a JSON list[dict] keyed by `hash`
# (CommitRef.hash) — NOT `sha`. A prior fix read `sha` and silently lost
# every ref; this test uses the real shape so the regression can't hide.
commits = [{"hash": f"hash{i}", "message": f"c{i}"} for i in range(5)]
task_obj = MagicMock()
task_obj.id = task_id
task_obj.status = "in_progress"
task_obj.commits = commits
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_obj]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
await c.i_am_idle(agent_id)
call_kwargs = task_svc.add_checkpoint.await_args
remaining = call_kwargs.kwargs.get("remaining_work", [])
# Last 3 commit hashes (the real persisted key) must appear in remaining_work.
last_3 = {c["hash"] for c in commits[-3:]}
mentioned = {entry for entry in remaining if isinstance(entry, str)}
assert last_3 & mentioned or any(h in str(remaining) for h in last_3)
@pytest.mark.asyncio
async def test_i_am_idle_checkpoint_failure_does_not_block_auto_pause() -> None:
"""If add_checkpoint raises, the auto-pause and idle response still succeed."""
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock()
task_obj.id = task_id
task_obj.status = "in_progress"
task_obj.commits = []
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = [task_obj]
task_svc.pause_for_agent = AsyncMock()
task_svc.add_checkpoint = AsyncMock(side_effect=RuntimeError("DB timeout"))
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
# The idle response must still succeed even though checkpoint write failed
assert body["error"] is None
assert body["status"] == "idle"
# The pause must still have happened
task_svc.pause_for_agent.assert_awaited_once_with(agent_id, task_id)
task_svc.mark_agent_idle.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_with_no_active_task_skips_checkpoint() -> None:
"""No active in_progress task → no auto-pause, no checkpoint written."""
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = []
task_svc.list_in_progress_for_agent.return_value = []
task_svc.add_checkpoint = AsyncMock()
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "idle"
task_svc.add_checkpoint.assert_not_awaited()