mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
A task re-claimed by a different agent (pool release, reaper unclaim, escalation redirect) left the prior holder's active work session open. WorkSessionService.get_active_for_task then ran a one-row query over the duplicates and raised MultipleResultsFound; the caught failure surfaced as the cryptic "'NoneType' object has no attribute 'id'" that crashed the claim/plan/start flow — so the task could never advance, the orchestrator re-spawned its PM every ~30s forever, and its dependents stayed blocked. Fixed at three layers: - active-session lookups return the most-recent session instead of raising - claiming a task supersedes any other agent's stale active session (the single-active-per-task invariant), in both WorkSessionService.create and TaskService._create_work_session_if_needed - a partial unique index (migration 047, which de-duplicates existing rows keeping the most recent) enforces it at the DB level; mirrored on the model Verified: 1614 tests green (work_session + gateway + services), ruff/mypy clean, migration chain applies + reverses, dedup proven on the real schema.
553 lines
17 KiB
Python
553 lines
17 KiB
Python
"""WorkSessionService coverage — create/update/lifecycle/PR tracking."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable, WorkSessionTable
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
|
from roboco.models.base import (
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
)
|
|
from roboco.models.work_session import (
|
|
WorkSessionCreate,
|
|
WorkSessionStatus,
|
|
WorkSessionUpdate,
|
|
)
|
|
from roboco.services.base import ConflictError, NotFoundError, ValidationError
|
|
from roboco.services.work_session import WorkSessionService
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def ws_setup(
|
|
db_session: AsyncSession,
|
|
) -> AsyncIterator[dict]:
|
|
agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Dev",
|
|
slug=f"be-dev-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(agent)
|
|
await db_session.flush()
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="W-Proj",
|
|
slug=f"w-proj-{uuid4().hex[:8]}",
|
|
git_url="https://example.com/r.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=agent.id,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
task = TaskTable(
|
|
id=uuid4(),
|
|
title="t",
|
|
description="d",
|
|
acceptance_criteria=["ac"],
|
|
status=TaskStatus.PENDING,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
project_id=project.id,
|
|
created_by=agent.id,
|
|
team=Team.BACKEND,
|
|
)
|
|
db_session.add(task)
|
|
await db_session.flush()
|
|
yield {
|
|
"svc": WorkSessionService(db_session),
|
|
"agent_id": agent.id,
|
|
"project_id": project.id,
|
|
"task_id": task.id,
|
|
}
|
|
|
|
|
|
def _payload(setup: dict, branch: str | None = None) -> WorkSessionCreate:
|
|
return WorkSessionCreate(
|
|
project_id=setup["project_id"],
|
|
task_id=setup["task_id"],
|
|
agent_id=setup["agent_id"],
|
|
branch_name=branch or f"feature/x-{uuid4().hex[:6]}",
|
|
base_branch="main",
|
|
target_branch="main",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Create / Get
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_work_session(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
assert ws.id is not None
|
|
assert ws.status == WorkSessionStatus.ACTIVE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_missing_project_raises(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
payload = _payload(ws_setup)
|
|
payload_dict = payload.model_dump()
|
|
payload_dict["project_id"] = uuid4()
|
|
with pytest.raises(ValidationError):
|
|
await svc.create(WorkSessionCreate(**payload_dict))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_missing_task_raises(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
payload = _payload(ws_setup)
|
|
payload_dict = payload.model_dump()
|
|
payload_dict["task_id"] = uuid4()
|
|
with pytest.raises(ValidationError):
|
|
await svc.create(WorkSessionCreate(**payload_dict))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_duplicate_active_raises(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
await svc.create(_payload(ws_setup))
|
|
with pytest.raises(ConflictError):
|
|
await svc.create(_payload(ws_setup))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
fetched = await svc.get(ws.id)
|
|
assert fetched is not None
|
|
assert fetched.id == ws.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_returns_none(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.get(uuid4()) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_or_raise(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
with pytest.raises(NotFoundError):
|
|
await svc.get_or_raise(uuid4())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_or_raise_returns_session(ws_setup: dict) -> None:
|
|
"""Line 136: get_or_raise returns the session when found."""
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
found = await svc.get_or_raise(ws.id)
|
|
assert found.id == ws.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_active_sessions_filtered_by_project(ws_setup: dict) -> None:
|
|
"""Line 295: project_id filter applied."""
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
sessions = await svc.list_active_sessions(project_id=ws.project_id)
|
|
assert any(s.id == ws.id for s in sessions)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pr_fields(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
updated = await svc.update(
|
|
ws.id,
|
|
WorkSessionUpdate(pr_number=42, pr_url="https://github.com/x/y/pull/42"),
|
|
)
|
|
assert updated is not None
|
|
_PR_NUM = 42
|
|
assert updated.pr_number == _PR_NUM
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert (await svc.update(uuid4(), WorkSessionUpdate(pr_number=1))) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Active-by-task lookups
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_for_task(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
active = await svc.get_active_for_task(ws_setup["task_id"])
|
|
assert active is not None
|
|
assert active.id == ws.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_active_for_task_and_agent(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
active = await svc.get_active_for_task_and_agent(
|
|
task_id=ws_setup["task_id"], agent_id=ws_setup["agent_id"]
|
|
)
|
|
assert active is not None
|
|
assert active.id == ws.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Listing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_by_agent(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
rows = await svc.list_by_agent(ws_setup["agent_id"])
|
|
assert ws.id in {r.id for r in rows}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_by_project(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
rows = await svc.list_by_project(ws_setup["project_id"])
|
|
assert ws.id in {r.id for r in rows}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_active_sessions(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
rows = await svc.list_active_sessions()
|
|
assert ws.id in {r.id for r in rows}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Commit + files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_commit(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
updated = await svc.add_commit(ws.id, "abc123def456")
|
|
assert updated is not None
|
|
assert "abc123def456" in updated.commits
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_commit_idempotent(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.add_commit(ws.id, "abc123")
|
|
again = await svc.add_commit(ws.id, "abc123")
|
|
assert again is not None
|
|
assert again.commits.count("abc123") == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_commit_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.add_commit(uuid4(), "abc123") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_files_modified(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
updated = await svc.add_files_modified(ws.id, ["a.py", "b.py"])
|
|
assert updated is not None
|
|
assert "a.py" in updated.files_modified
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_files_modified_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.add_files_modified(uuid4(), ["a.py"]) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PR lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_pr(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
updated = await svc.create_pr(ws.id, 7, "https://github.com/x/y/pull/7")
|
|
_PR_NUM = 7
|
|
assert updated is not None
|
|
assert updated.pr_number == _PR_NUM
|
|
assert updated.pr_status == "open"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_pr_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.create_pr(uuid4(), 1, "u") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pr_status(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.create_pr(ws.id, 1, "u")
|
|
updated = await svc.update_pr_status(ws.id, "merged")
|
|
assert updated is not None
|
|
assert updated.pr_status == "merged"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_pr_status_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.update_pr_status(uuid4(), "merged") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_pr_completes_session(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.create_pr(ws.id, 99, "u")
|
|
merged = await svc.merge_pr(ws.id, ws_setup["agent_id"])
|
|
assert merged is not None
|
|
assert merged.status == WorkSessionStatus.COMPLETED
|
|
assert merged.pr_status == "merged"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merge_pr_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.merge_pr(uuid4(), uuid4()) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle: complete / abandon / close
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_session(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
completed = await svc.complete(ws.id)
|
|
assert completed is not None
|
|
assert completed.status == WorkSessionStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_returns_none_when_already_terminal(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.complete(ws.id)
|
|
assert await svc.complete(ws.id) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_complete_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.complete(uuid4()) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_abandon_session(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
abandoned = await svc.abandon(ws.id, reason="cancelled")
|
|
assert abandoned is not None
|
|
assert abandoned.status == WorkSessionStatus.ABANDONED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_abandon_returns_none_when_already_terminal(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.abandon(ws.id)
|
|
assert await svc.abandon(ws.id) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_abandon_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.abandon(uuid4()) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_session(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
closed = await svc.close(ws.id, reason="done")
|
|
assert closed is not None
|
|
assert closed.status == WorkSessionStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_idempotent(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.close(ws.id)
|
|
again = await svc.close(ws.id)
|
|
assert again is not None
|
|
assert again.status == WorkSessionStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_returns_none_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.close(uuid4()) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_files_changed(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.add_files_modified(ws.id, ["a.py", "b.py"])
|
|
files = await svc.files_changed(ws.id)
|
|
assert "a.py" in files
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_files_changed_empty_for_missing(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
assert await svc.files_changed(uuid4()) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_unpushed_commits_true_when_no_pr(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.add_commit(ws.id, "abc123")
|
|
assert await svc.has_unpushed_commits(ws.id) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_unpushed_commits_false_after_pr(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
ws = await svc.create(_payload(ws_setup))
|
|
await svc.add_commit(ws.id, "abc123")
|
|
await svc.create_pr(ws.id, 1, "u")
|
|
assert await svc.has_unpushed_commits(ws.id) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-active-per-task invariant (migration 047) — the i_will_plan wedge fix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _second_agent(db_session: AsyncSession) -> AgentTable:
|
|
"""A distinct agent to simulate a re-claim by someone else."""
|
|
agent = AgentTable(
|
|
id=uuid4(),
|
|
name="Dev2",
|
|
slug=f"be-dev-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(agent)
|
|
await db_session.flush()
|
|
return agent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_supersedes_other_agents_active_session(
|
|
ws_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""A re-claim by a different agent abandons the stale session (no dup ACTIVE)."""
|
|
svc = ws_setup["svc"]
|
|
first = await svc.create(_payload(ws_setup)) # agent A
|
|
agent_b = await _second_agent(db_session)
|
|
second = await svc.create(
|
|
WorkSessionCreate(
|
|
project_id=ws_setup["project_id"],
|
|
task_id=ws_setup["task_id"],
|
|
agent_id=agent_b.id,
|
|
branch_name=f"feature/x-{uuid4().hex[:6]}",
|
|
base_branch="main",
|
|
target_branch="main",
|
|
)
|
|
)
|
|
# The prior holder's session is abandoned; exactly one ACTIVE remains.
|
|
refreshed_first = await svc.get(first.id)
|
|
assert refreshed_first is not None
|
|
assert refreshed_first.status == WorkSessionStatus.ABANDONED
|
|
active = await svc.get_active_for_task(ws_setup["task_id"])
|
|
assert active is not None
|
|
assert active.id == second.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supersede_helper_keeps_named_agent(ws_setup: dict) -> None:
|
|
svc = ws_setup["svc"]
|
|
keep = await svc.create(_payload(ws_setup)) # agent A
|
|
n = await svc.supersede_active_sessions_for_task(
|
|
ws_setup["task_id"], keep_agent_id=ws_setup["agent_id"]
|
|
)
|
|
assert n == 0 # nothing to supersede — A is the kept agent
|
|
refreshed = await svc.get(keep.id)
|
|
assert refreshed is not None
|
|
assert refreshed.status == WorkSessionStatus.ACTIVE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_unique_index_blocks_two_active_for_task(
|
|
ws_setup: dict, db_session: AsyncSession
|
|
) -> None:
|
|
"""The DB backstop: a second ACTIVE row for one task violates the index."""
|
|
svc = ws_setup["svc"]
|
|
await svc.create(_payload(ws_setup)) # one ACTIVE session
|
|
agent_b = await _second_agent(db_session)
|
|
# Bypass the service supersede and force a raw duplicate ACTIVE row.
|
|
dup = WorkSessionTable(
|
|
project_id=ws_setup["project_id"],
|
|
task_id=ws_setup["task_id"],
|
|
agent_id=agent_b.id,
|
|
branch_name="feature/dup",
|
|
base_branch="main",
|
|
target_branch="main",
|
|
status=WorkSessionStatus.ACTIVE,
|
|
)
|
|
db_session.add(dup)
|
|
with pytest.raises(IntegrityError):
|
|
await db_session.flush()
|