mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(gateway): heartbeat on content-write success, single-claimant gate, progress soft-warn
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.
This commit is contained in:
@@ -60,6 +60,7 @@ async def test_commit_prefixes_with_task_id_short() -> None:
|
||||
t = MagicMock(
|
||||
id=tid,
|
||||
assigned_to=aid,
|
||||
active_claimant_id=aid,
|
||||
plan="x",
|
||||
status="in_progress",
|
||||
branch_name="feature/backend/abcd1234",
|
||||
@@ -95,6 +96,7 @@ async def test_commit_strips_then_re_adds_prefix() -> None:
|
||||
t = MagicMock(
|
||||
id=tid,
|
||||
assigned_to=aid,
|
||||
active_claimant_id=aid,
|
||||
plan="x",
|
||||
status="in_progress",
|
||||
branch_name="feature/backend/abcd1234",
|
||||
@@ -127,6 +129,7 @@ async def test_commit_prefix_collapses_multiple_spaces() -> None:
|
||||
t = MagicMock(
|
||||
id=tid,
|
||||
assigned_to=aid,
|
||||
active_claimant_id=aid,
|
||||
plan="x",
|
||||
status="in_progress",
|
||||
branch_name="feature/backend/abcd1234",
|
||||
|
||||
@@ -81,7 +81,10 @@ async def test_commit_descriptive_with_active_task_succeeds() -> None:
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
task_obj = MagicMock(
|
||||
id=task_id, status="in_progress", branch_name="feature/backend/abc"
|
||||
id=task_id,
|
||||
status="in_progress",
|
||||
branch_name="feature/backend/abc",
|
||||
active_claimant_id=agent_id,
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = task_obj
|
||||
@@ -133,7 +136,10 @@ async def test_commit_strips_existing_task_prefix() -> None:
|
||||
task_id = uuid4()
|
||||
expected_prefix = f"[{str(task_id)[:8]}]"
|
||||
task_obj = MagicMock(
|
||||
id=task_id, status="in_progress", branch_name="feature/backend/abc"
|
||||
id=task_id,
|
||||
status="in_progress",
|
||||
branch_name="feature/backend/abc",
|
||||
active_claimant_id=agent_id,
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = task_obj
|
||||
@@ -214,7 +220,10 @@ async def test_commit_allows_documenter_role() -> None:
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
task_obj = MagicMock(
|
||||
id=task_id, status="awaiting_documentation", branch_name="feature/backend/abc"
|
||||
id=task_id,
|
||||
status="awaiting_documentation",
|
||||
branch_name="feature/backend/abc",
|
||||
active_claimant_id=agent_id,
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.agent_for.return_value = MagicMock(role="documenter")
|
||||
@@ -747,7 +756,12 @@ async def test_reflect_thin_note_records_without_rejection() -> None:
|
||||
|
||||
|
||||
def _active_task(agent_id: object) -> MagicMock:
|
||||
return MagicMock(id=uuid4(), assigned_to=agent_id, status="in_progress")
|
||||
return MagicMock(
|
||||
id=uuid4(),
|
||||
assigned_to=agent_id,
|
||||
active_claimant_id=agent_id,
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -78,6 +78,7 @@ async def test_commit_active_task_returned_must_be_caller_owned() -> None:
|
||||
id=task_id,
|
||||
status="in_progress",
|
||||
assigned_to=agent_id,
|
||||
active_claimant_id=agent_id,
|
||||
branch_name="feature/backend/abc",
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user