mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
commit()/progress()/note() now refresh last_heartbeat_at on the success path (best-effort, suppressed), not only on rejection — an actively writing agent no longer looks idle to the reaper between verb successes. commit()/progress() verify the caller holds the active claim (active_claimant_id), not merely the historical assigned_to, so a reaped or handed-off assignee can no longer write onto a freed task; a non-holder gets a not_authorized envelope with a clear remediate. progress() with no plan_step on a task that has steps is accepted (product decision for narrative mid-step updates) and logs a soft warning instead of rejecting.
283 lines
9.0 KiB
Python
283 lines
9.0 KiB
Python
"""Real-DB tests for content-action run-killers.
|
|
|
|
Three contracts, all exercised against the live test Postgres so the bug
|
|
boundaries (heartbeat write, claimant gate, plan-step soft-warn) are real:
|
|
|
|
1. HEARTBEAT-ON-SUCCESS — commit() must refresh ``last_heartbeat_at`` on the
|
|
success path, not only on rejection. Without it an actively-committing
|
|
agent looks idle to the reaper between verb successes.
|
|
2. CLAIM-OWNERSHIP — commit()/progress() must verify the caller is the active
|
|
claimant (``active_claimant_id``), not merely the historical ``assigned_to``.
|
|
A reaped/stale assignee whose claim was released must not be able to write.
|
|
3. PROGRESS SOFT-WARN — progress() with no ``plan_step`` on a stepped task is
|
|
accepted (product decision) but emits a warning.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import AsyncMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
import structlog
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
from roboco.models.base import (
|
|
AgentRole,
|
|
AgentStatus,
|
|
Complexity,
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
Team,
|
|
)
|
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
|
from roboco.services.task import TaskService
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
async def _seed_dev_agent(session: AsyncSession, slug_prefix: str) -> UUID:
|
|
agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Backend Dev",
|
|
slug=f"{slug_prefix}-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=["python"],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
session.add(agent)
|
|
await session.flush()
|
|
return UUID(str(agent.id))
|
|
|
|
|
|
async def _seed_claimed_task(
|
|
session: AsyncSession,
|
|
*,
|
|
assigned_to: UUID,
|
|
active_claimant_id: UUID | None,
|
|
plan: dict | None,
|
|
status: TaskStatus = TaskStatus.IN_PROGRESS,
|
|
) -> UUID:
|
|
"""Seed a project + system creator + an in-progress task and return its id."""
|
|
system_agent = AgentTable(
|
|
id=uuid4(),
|
|
name="System",
|
|
slug=f"system-{uuid4().hex[:8]}",
|
|
role=AgentRole.SYSTEM,
|
|
team=None,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="system",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
session.add(system_agent)
|
|
await session.flush()
|
|
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="Run-killer Test Project",
|
|
slug=f"runkiller-{uuid4().hex[:8]}",
|
|
git_url="https://github.com/example/runkiller.git",
|
|
default_branch="main",
|
|
protected_branches=["main"],
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=system_agent.id,
|
|
is_active=True,
|
|
)
|
|
session.add(project)
|
|
await session.flush()
|
|
|
|
task = TaskTable(
|
|
id=uuid4(),
|
|
title="Run-killer target task",
|
|
description="Synthetic task for content-action run-killer tests.",
|
|
acceptance_criteria=["content actions behave"],
|
|
status=status,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
project_id=project.id,
|
|
branch_name="feature/backend/RUNKILL1",
|
|
created_by=system_agent.id,
|
|
assigned_to=assigned_to,
|
|
active_claimant_id=active_claimant_id,
|
|
team=Team.BACKEND,
|
|
dependency_ids=[],
|
|
blocker_ids=[],
|
|
sequence=0,
|
|
plan=plan,
|
|
estimated_complexity=Complexity.LOW,
|
|
checkpoints=[],
|
|
progress_updates=[],
|
|
commits=[],
|
|
documents=[],
|
|
last_heartbeat_at=None,
|
|
)
|
|
session.add(task)
|
|
await session.commit()
|
|
return UUID(str(task.id))
|
|
|
|
|
|
def _content_actions(session: AsyncSession) -> ContentActions:
|
|
"""ContentActions backed by a real TaskService; only git is faked.
|
|
|
|
The bug boundaries (heartbeat write, claimant gate) live in
|
|
ContentActions + TaskService against the DB — git is intentionally the
|
|
one faked dependency since these tests don't exercise real git work.
|
|
"""
|
|
git = AsyncMock()
|
|
git.commit.return_value = {"sha": "abc12345def"}
|
|
return ContentActions(
|
|
ContentActionsDeps(
|
|
task=TaskService(session),
|
|
git=git,
|
|
messaging=AsyncMock(),
|
|
a2a=AsyncMock(),
|
|
journal=AsyncMock(),
|
|
workspace=AsyncMock(),
|
|
notifications=AsyncMock(),
|
|
notification_delivery=AsyncMock(),
|
|
evidence_repo=AsyncMock(),
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_refreshes_heartbeat_on_success(db_session: AsyncSession) -> None:
|
|
"""commit() success must advance last_heartbeat_at past the claim time.
|
|
|
|
Regression: the success path returned ok() without touching the
|
|
heartbeat, so an actively-committing agent looked idle to the reaper.
|
|
"""
|
|
agent_id = await _seed_dev_agent(db_session, "be-dev")
|
|
task_id = await _seed_claimed_task(
|
|
db_session,
|
|
assigned_to=agent_id,
|
|
active_claimant_id=agent_id,
|
|
plan={"steps": ["build"]},
|
|
)
|
|
svc = TaskService(db_session)
|
|
# Stamp an explicitly-old heartbeat so the success-path refresh is
|
|
# unambiguous (no reliance on sub-millisecond clock resolution).
|
|
stale = datetime.now(UTC) - timedelta(minutes=5)
|
|
row = await svc.get(task_id)
|
|
assert row is not None
|
|
row.last_heartbeat_at = stale
|
|
await db_session.commit()
|
|
|
|
ca = _content_actions(db_session)
|
|
env = await ca.commit(
|
|
agent_id=agent_id,
|
|
message="feat(api): add /healthz endpoint for liveness checks",
|
|
)
|
|
await db_session.commit()
|
|
|
|
assert env.as_dict()["error"] is None
|
|
refreshed = await svc.get(task_id)
|
|
assert refreshed is not None
|
|
assert refreshed.last_heartbeat_at is not None
|
|
assert refreshed.last_heartbeat_at > stale, (
|
|
"commit() must refresh the claimant heartbeat on success, "
|
|
"not only on the rejection path"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_rejected_when_claim_released(db_session: AsyncSession) -> None:
|
|
"""A stale/reaped assignee whose active claim was cleared cannot commit.
|
|
|
|
assigned_to still points at the old agent, but active_claimant_id is
|
|
NULL (claim released by the reaper). The historical assignee must be
|
|
refused with not_authorized rather than writing onto a freed task.
|
|
"""
|
|
agent_id = await _seed_dev_agent(db_session, "be-dev")
|
|
task_id = await _seed_claimed_task(
|
|
db_session,
|
|
assigned_to=agent_id,
|
|
active_claimant_id=None,
|
|
plan={"steps": ["build"]},
|
|
)
|
|
|
|
ca = _content_actions(db_session)
|
|
env = await ca.commit(
|
|
agent_id=agent_id,
|
|
message="feat(api): add /healthz endpoint for liveness checks",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized", (
|
|
"an assignee whose active claim was released must not commit"
|
|
)
|
|
_ = task_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commit_rejected_when_another_agent_holds_claim(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""Another agent holding the active claim blocks the historical assignee."""
|
|
old_agent = await _seed_dev_agent(db_session, "be-dev")
|
|
new_agent = await _seed_dev_agent(db_session, "be-dev")
|
|
await _seed_claimed_task(
|
|
db_session,
|
|
assigned_to=old_agent,
|
|
active_claimant_id=new_agent,
|
|
plan={"steps": ["build"]},
|
|
)
|
|
|
|
ca = _content_actions(db_session)
|
|
env = await ca.commit(
|
|
agent_id=old_agent,
|
|
message="feat(api): add /healthz endpoint for liveness checks",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_no_plan_step_on_stepped_task_is_accepted(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""progress() with no plan_step on a stepped task is accepted + warns.
|
|
|
|
Product decision: a narrative progress entry without a plan_step on a
|
|
task that has plan sub_tasks is allowed (not rejected), but emits a
|
|
soft warning so the gap is visible.
|
|
"""
|
|
agent_id = await _seed_dev_agent(db_session, "be-dev")
|
|
task_id = await _seed_claimed_task(
|
|
db_session,
|
|
assigned_to=agent_id,
|
|
active_claimant_id=agent_id,
|
|
plan={"sub_tasks": [{"id": "s1", "title": "build"}]},
|
|
)
|
|
|
|
ca = _content_actions(db_session)
|
|
with structlog.testing.capture_logs() as logs:
|
|
env = await ca.progress(
|
|
agent_id=agent_id,
|
|
task_id=task_id,
|
|
message="made some mid-step progress without finishing a step",
|
|
)
|
|
await db_session.commit()
|
|
|
|
body = env.as_dict()
|
|
assert body["error"] is None, "missing plan_step on a stepped task must be accepted"
|
|
assert body["task_id"] == str(task_id)
|
|
assert any(
|
|
entry.get("log_level") == "warning"
|
|
and "plan_step" in str(entry.get("event", ""))
|
|
for entry in logs
|
|
), "a soft warning must be emitted when plan_step is omitted on a stepped task"
|