Files
roboco/tests/integration/test_task_service_misc.py
T
889f3689e7 MegaTask (#248)
* feat(batch): batch_id + collision descriptor columns

Sequenced batch intake ("Mega task") foundation: tasks.batch_id (indexed)
groups a batch of top-level tasks created together; intends_to_touch (text[]),
adds_migration and touches_shared (bool, NOT NULL default false) are the
per-task collision surface the SequencingService will read to wire dependency
waves. Mirrored on the Task model + TaskCreateRequest and wired through
TaskService.create. Migration 046 (real upgrade->downgrade->upgrade verified
vs a throwaway pgvector PG); a non-batch task declares no surface (defaults).

Task 1 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): flag + draft collision descriptors

Default-off ROBOCO_BATCH_INTAKE_ENABLED (config + FEATURE_FLAGS + panel card);
the propose_draft tool doc + the TS DraftProposal gain the per-task collision
surface intends_to_touch / adds_migration / touches_shared. The draft is a loose
dict so the descriptors ride it through the relay intact (test asserts the
forwarded payload); the analyzer (Task 3) reads them to wire dependency waves.

Task 2 of the 0.11.0 sequenced-batch-intake plan.

* feat(batch): deterministic collision-sequencing analyzer

SequencingService.analyze turns a batch's per-task collision surfaces into a
dependency DAG + execution waves — correctness in CODE, not agent judgment.
Rules in order: file overlap serializes (more-important first), migrations form
a serial chain (no concurrent Alembic heads), touches_shared runs last, cell
contention warns (never serializes); then dedupe, existence + cycle check, and
Kahn topological layering. Pure (no DB/services); SequencingError on a cycle or
out-of-range edge.

Golden test reproduces the CEO's hand-sequenced 4 waves of the 11-item
guard-core-app batch (the effort that deadlocked the Main PM): S6 alone last,
the R1/R3/R4 migration chain, R2/R3/S8 serialized on the shared threat service,
S1/S2/S7 in one parallel wave.

Task 3 of the 0.11.0 sequenced-batch-intake plan.

* chore(batch): brand the user-facing surfaces "MegaTask"

The user-facing name is MegaTask: the feature-flag label is "MegaTask intake",
the panel flag-card and the config description lead with MegaTask. Internal
names stay technical (batch_intake_enabled, batch_id, SequencingService).

* chore(batch): drop the feature flag — MegaTask is a core intake scope

MegaTask is additive and opt-in by its own nature (the Prompter proposes a
batch only when the CEO asks for several tasks; single-task intake is
unchanged), so there is no risk surface a flag protects — 'don't create a
MegaTask' is the off switch. Remove batch_intake_enabled from config, the
FEATURE_FLAGS registry, the panel flag card, and its tests. MegaTask will be
a third scope option in the Intake modal (single-cell / multi-project /
MegaTask), not a toggle.

* feat(batch): MegaTask identity predicate + orchestrator branchless recognition

The single source of truth for the umbrella's exemptions: pure
is_batch_umbrella / is_batch_root_subtask / is_branchless_coordination
(foundation/policy/batch.py) — an umbrella has a batch_id and is top-level; a
root-subtask shares the batch_id but is parented. The orchestrator's
_is_coordination_task now consults is_branchless_coordination, so a MegaTask
umbrella is recognized as doing no git of its own (git-exempt at spawn-readiness
/ stuck-detection) exactly like a product fan-out root. Non-batch behavior is
identical (the predicate reduces to the old no-project+product check; the
orchestrator coordination suite stays green), and the umbrella branch is inert
until the create path exists.

First slice of the MegaTask umbrella enforcement (branchless guard).

* feat(batch): branchless umbrella guard across the git-exemption sites

A MegaTask umbrella does no git of its own — every git-exemption site in
TaskService now consults the shared is_branchless_coordination predicate
instead of an inline product-only check, so the umbrella's exemptions
cannot drift between sites:

- the claimed->in_progress branch gate (GitContext.is_coordination) lets
  an unbranched umbrella reach in_progress and delegate;
- _ensure_branch_for_task short-circuits an umbrella to "" instead of the
  misconfigured raise (the claim path ignores the return, treating it as
  branchless);
- CEO-reject routing sends a rejected umbrella to the Main PM in PENDING
  (needs_revision is developer-claim-only and would deadlock it).

Covers both shapes via the predicate (product fan-out root OR umbrella);
a batch root-subtask keeps its own branch/PR. Adds orchestrator
recognition tests for the umbrella plus claim/branch/reject integration
tests.

* feat(batch): umbrella assembles no PR; completes branchless

submit_root now hard-rejects a MegaTask umbrella up front (a preflight
that also folds in the unknown-role refusal to stay within the
return-count budget): the umbrella spans many projects with no single
master, so each root-subtask opens and is reviewed on its own PR — the
umbrella never enters the in-path review gate. The Main PM completes it
directly once every root-subtask is terminal.

Umbrella completion needs no new code: it is branchless (no branch_name),
so _main_pm_complete_guard already accepts it from in_progress, checks
all_subtasks_terminal, and main_pm_complete walks it to awaiting_pm_review
and escalates to the CEO with no PR creation — exactly the product
fan-out root path. Adds the submit_root-reject and umbrella-completion
gateway tests; pins batch_id=None on the normal-root submit_root test
(a MagicMock auto-attr would otherwise read as an umbrella).

* feat(batch): MegaTask create path — umbrella + sequenced root-subtasks

PrompterService.confirm_live_batch turns N confirmed drafts into a real
MegaTask: it builds each draft's collision surface, runs the pure
SequencingService to get conflict-free waves, creates the branchless
umbrella (batch_id, no project/product), then one root-subtask per draft
(own project, parent=umbrella, sequence=wave index, descriptors), and
wires the analyzer's edges through add_dependency so the existing
dependency-gate runs the waves in order. The route picks the start path
like a single confirm: 'board' holds the root-subtasks in BACKLOG for the
batch review; 'main_pm' creates them PENDING so wave 0 dispatches at once.

create_task_from_draft gains a BatchPlacement (parent/batch/sequence/
team_override) and forwards the collision descriptors; the exactly-one-
target rule (here and the TaskService.create invariant) is relaxed for an
umbrella, which legitimately targets neither. New route
POST /live/{session}/confirm-batch + BatchConfirmRequest mirror the single
confirm. Adds the structural-invariant + board-hold + empty-batch tests.

* feat(batch): release MegaTask root-subtasks on CEO approval; board awareness

The board route holds a MegaTask's root-subtasks in BACKLOG so the work
waits for the batch review. approve_and_start (CEO gate #1, board->Main PM)
now releases them via _activate_batch_root_subtasks: each held child flips
BACKLOG -> PENDING + team=main_pm so the dependency-gate dispatches wave 0.
No-op for a non-umbrella; idempotent (children past BACKLOG untouched).

The Product Owner and Head of Marketing identity prompts gain a MegaTask
section so they review the whole batch + wave plan and adjust scope before
sign-off (they review drafts; the umbrella is their unit). Also extracts
the create() target invariant into _require_target_or_umbrella to keep the
method under the complexity gate after the umbrella exemption. Adds the
umbrella-approval activation test.

* feat(batch): multi-project intake scope for MegaTask

A MegaTask spans several possibly-unrelated repos, so the intake chat can
now be scoped to an explicit project list (not just one project or one
product). StartLiveRequest gains project_ids; /live/start threads it
through start/spawn_intake_session -> _spawn_intake_container ->
_clone_intake_scope. The multi-repo clone machinery already existed for
products; _intake_scope_slugs now also resolves an explicit project_ids
set (split into _slugs_for_project_ids / _slugs_for_product), cloning each
repo with the first as the primary cwd and the siblings readable. Scope
validation is now 'exactly one of project_slug / product_id / project_ids'
via the shared _require_one_intake_scope. Adds scope-resolution, spawn,
and route tests for the MegaTask path.

* feat(batch): propose_batch intake tool (MegaTask multi-draft hand-off)

The intake agent can now hand the panel a whole MegaTask in one tool call.
Both intake paths gain propose_batch alongside propose_draft:
- Claude (intake_driver): a propose_batch tool registered on the in-SDK
  MCP server + allowlisted; the driver intercepts the ToolUseBlock and
  emits ONE StreamChunk(kind="batch") carrying {drafts:[...], title}.
- grok (intake_server): a propose_batch tool that POSTs a "batch" relay
  event via the shared _post_event helper (post_draft/post_batch).

A batch carries N drafts, each the propose_draft shape PLUS its own
project_id (a MegaTask spans unrelated repos) and collision surface so the
analyzer sequences the waves. The prompter prompt documents the MegaTask
scope + when to call propose_batch. Adds Claude-normalize and grok-relay
tests for the batch path.

* feat(batch): MegaTask intake panel — third scope, batch review, waves

The panel now drives a MegaTask end to end. The intake modal gains a
third scope, 'MegaTask', beside Single cell and Board-led: a multi-project
checklist (a MegaTask spans several possibly-unrelated repos), validated
to at least two. start() sends project_ids; use-prompter accumulates the
agent's single propose_batch hand-off as a 'batch' SSE event into a
BatchProposal and lands in a new batch_preview state.

A new BatchReviewCard lists every proposed task with its target project +
collision-surface badges (migration / shared) and offers one start path
for the whole batch — Board review & Start or Approve & Start — wired to
confirmBatch → POST /confirm-batch. The success card shows the sequenced
result: N tasks in M waves (+ any advisory notes). prompter.ts gains the
DraftScale 'megatask' + the BatchConfirm payload/result types; the SSE
client allows the 'batch' kind. Panel typecheck + lint + 113 tests green.

* docs(batch): MegaTask across changelog, CLAUDE.md, site, and RAG

The four documentation obligations for the MegaTask feature:
- CHANGELOG: an Unreleased entry covering the umbrella model, sequencing,
  multi-project intake, propose_batch, and the create/approval path.
- CLAUDE.md: a MegaTask section (identity predicate, umbrella/root-subtask
  hierarchy, sequencing rules, intake + create path, board activation).
- Published site: a user-facing company/megatask.md (scopes, waves, the
  umbrella, the two start buttons) + nav entry; a pointer added to the
  intake chapter of the Tour.
- RAG corpus: workflows/megatask.md so the Main PM (and any agent) can
  retrieve the umbrella's branchless / no-PR / completion rules at runtime.

The runtime concurrent-migration guard is intentionally NOT added: the
analyzer already chains migration-adders into dependencies and the
dependency-gate serializes them, so a separate guard would be dead code.

* feat(batch): batch_id guardrail + wave preview + batch_id on TaskResponse

Guardrail (CEO): a batch_id is denied on any task that is not a well-formed
MegaTask member. is_valid_batch_shape permits batch_id only on an umbrella
(no parent → must target neither project nor product) or a root-subtask
(has a parent → exactly one target); TaskService.create enforces it AND
verifies a root-subtask's parent is the batch umbrella (same batch_id,
top-level). This closes a latent hole: is_batch_umbrella is true for a
batch_id + no-parent task even with a project, so a stray batch_id could
have spoofed the branchless branch-gate / no-PR exemption. (The public
task API never exposed batch_id for write; this guards the service layer.)

Wave preview: PrompterService.preview_batch + POST .../preview-batch
compute a MegaTask's waves from the proposed drafts WITHOUT creating
anything, so the panel can show the sequencing before confirm. Extracted
_sequence_drafts as the single source shared by preview and confirm, so
the previewed waves are exactly the ones wired.

TaskResponse now carries batch_id so the panel can badge the umbrella.

* feat(batch): MegaTask review — project editor, wave preview, persistence, badge

Closes the panel gaps in the MegaTask review experience:
- Per-task project editor: each proposed task gets an inline project
  Select (updateBatchDraftProject), so a task the agent put in the wrong
  or no repo can be fixed before launch — not only by re-chatting. Launch
  stays blocked until every task has a project.
- Wave preview: on a batch proposal the panel fetches POST .../preview-batch
  (no task created) and shows the conflict-free wave plan, so the human
  reviews the sequencing before confirming.
- Refresh durability: the MegaTask review (batch + waves + projectIds) is
  persisted, so a browser reload mid-review restores it like a single draft.
- MegaTask badge: TaskResponse exposes batch_id, the panel Task type
  carries it, and the task table badges the umbrella row 'MegaTask'.

Panel typecheck + lint + 113 tests green.

* test(batch): stub task carries batch_id for task_to_response

task_to_response now serializes batch_id (TaskResponse field), so the
_stub_task SimpleNamespace fixture must provide it — without it the reader
hit AttributeError, failing the 8 task-schema serialization/enrichment
tests. Test-only; the real TaskTable carries the column (migration 046).

* fix(batch): close MegaTask audit gaps — completion crash, analyzer cycle, guardrails

An adversarial multi-agent audit of the feature surfaced 20 verified gaps;
this closes the backend ones.

HIGH:
- Umbrella completion crashed. escalate_to_ceo hard-required a pr_number,
  which a branchless umbrella never has, so main_pm_complete dereferenced
  None. Both pr_number gates now waive a MegaTask umbrella (escalate_to_ceo
  + the awaiting_pm_review->awaiting_ceo_approval lifecycle gate via a new
  GitContext.is_umbrella), and main_pm_complete guards a None return. The
  completion test had mocked escalate_to_ceo, hiding it — now a real
  service test covers the waiver.
- The collision analyzer could fabricate a cycle (a touches_shared +
  adds_migration draft overlapping another migration draft) and raise
  SequencingError — a bare ValueError that escaped as an opaque 500. The
  migration chain is now shared-last-aware (never contradicts rule 3), and
  _sequence_drafts translates SequencingError to a clean 400.

MEDIUM:
- Collisions are now project-scoped: two repos can't collide on a
  coincidental path or serialize independent migrations (DraftSurface
  carries project_id; rules 1/2/3 respect it).
- The batch_id guardrail ran only at create. update() + the PATCH
  null-clear path now re-assert is_valid_batch_shape, so a mutation can't
  break a member's shape and spoof the branchless exemption.
- A draft missing title/acceptance_criteria now raises ValidationError
  (was a bare KeyError -> 500).
- confirm_live_batch re-asserts every draft targets a scoped project and
  the batch spans >=2 distinct projects (project_ids added to the request).
- Route-level tests for confirm-batch / preview-batch.

LOW: strict multi-repo clone (fail loud on any unresolvable project);
malformed/empty propose_batch surfaces an error chunk (Claude) / refuses
to POST (grok) instead of silently acking; dropped malformed drafts are
counted and surfaced; stale grok intake docstrings updated.

* fix(batch): MegaTask panel + doc audit gaps

Frontend half of the audit fixes:
- The confirm payload now carries project_ids (the schema requires it), and
  the panel re-checks every task targets one of the scoped repos before
  launching, naming the offending task.
- The Review-MegaTask project picker is filtered to the scoped repos and
  the per-task validity (border + launch gate) keys off scoped membership,
  so a task can only be (re)pointed at an in-scope project — also fixing the
  case where the agent emitted a non-UUID / unknown project.
- Dropped malformed drafts are surfaced as a chat error so the human knows
  the batch shrank instead of silently confirming fewer tasks.
- Doc wording: a wave releases on the previous wave's terminal state
  (normally a merge; a cancellation releases it too), not strictly 'merged'.

* test(batch): lock the CEO's EXACT 4-wave hand-sequencing as the golden bar

The golden test asserted the constraints (S6 last, the migration chain, the
shared-threats serialization, S1/S2/S7 parallel) but not the full wave
partition. The bar for MegaTask is 'reproduce my exact waves or it's not
done', so assert the exact 4-wave partition the analyzer produces for the
guard-core-app batch:
  wave 1: R1 R2 S1 S2 S3 S5 S7  ·  wave 2: R3  ·  wave 3: R4 S8  ·  wave 4: S6
Confirmed unchanged by the audit's analyzer fixes (no migration is shared;
single project).

* fix(batch): tolerate a stub task in assert_batch_shape_intact

The batch-shape re-validation read task.batch_id directly, but update()'s
partial-caller contract is exercised with a SimpleNamespace stub that has no
batch_id column → AttributeError. Use getattr(..., None) for batch_id and the
shape fields so the guard no-ops on any task lacking the column (a stub, or a
non-batch task) while still enforcing on a real batch member.

* fix(orchestrator): authenticate internal API self-calls with the system identity

The dispatcher httpx clients were built without an agent identity, so the
orchestrator's self-PATCHes to /api/tasks/{id} (auto-block, auto-resume,
auto-recover, SLA annotation) were rejected 401 "Missing X-Agent-ID" and
silently no-op'd. The auto-resume that lifts a PM's paused parent could never
write, so paused/blocked parents stayed wedged and stranded their dependents
(the fe-pm/be-pm respawn churn seen in prod).

Header propagation was inconsistent across the separate AsyncClient call-sites:
only the main dispatch client carried the system identity; the readiness and
sweep clients did not. Hoist the identity into a shared _SYSTEM_API_HEADERS
constant and apply it to every API-facing dispatcher client. The system role
holds TaskAction.ASSIGN, so it is authorized for the audited admin_set_status
path those write routes use. The external provider-recovery probe client is
intentionally left untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-24 01:15:57 +02:00

1349 lines
44 KiB
Python

"""TaskService coverage — final misc paths to push to 100%.
Targets:
- _append_capped truncation
- _default_claim_statuses for role-specific
- extract_original_developer invalid UUID
- _validate_parent_depth circular + max-depth + missing parent
- activate without project_id
- branch creation methods (with git mocks)
- claim role validations
- TaskLifecycleError catches in unclaim_for_reaper / unclaim_for_agent
- docs_complete branches (no notes / no assigned_to / has documenter)
- completion_notes early-return
- get_completing_agent_role missing agent
- complete main_pm escalation to CEO for root parent
- complete with PR not merged
- list_by_team_or_assignee with no conditions
- escalate_to_ceo_for_agent → inner escalate returns None
- mark_agent_idle missing agent
- qa_fail mismatch logging
- cell_pm_complete missing task
- get_task_service factory
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable, WorkSessionTable
from roboco.enforcement import TaskLifecycleError
from roboco.foundation.policy.content import markers
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.models.task import TaskCreateRequest
from roboco.models.work_session import WorkSessionStatus
from roboco.services.base import ValidationError
from roboco.services.task import (
TaskService,
_append_capped,
_default_claim_statuses,
extract_original_developer,
get_task_service,
)
from roboco.templates.git.constants import MAX_TASK_DEPTH
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy import Table
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def task_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="P",
slug=f"p-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
default_branch="main",
assigned_cell=Team.BACKEND,
created_by=agent.id,
)
db_session.add(project)
await db_session.flush()
yield {
"svc": TaskService(db_session),
"agent_id": agent.id,
"project_id": project.id,
"project_slug": project.slug,
"db": db_session,
}
def _req(setup: dict, **overrides: Any) -> TaskCreateRequest:
return TaskCreateRequest(
title=overrides.pop("title", "t"),
description=overrides.pop("description", "d"),
acceptance_criteria=overrides.pop("acceptance_criteria", ["ac"]),
team=overrides.pop("team", Team.BACKEND),
created_by=setup["agent_id"],
project_id=setup["project_id"],
task_type=overrides.pop("task_type", TaskType.CODE),
nature=overrides.pop("nature", TaskNature.TECHNICAL),
estimated_complexity=overrides.pop("estimated_complexity", Complexity.MEDIUM),
**overrides,
)
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
def test_append_capped_truncates_when_over_max() -> None:
"""When the joined notes exceed _MAX_NOTES_CHARS, oldest gets dropped."""
big_existing = "OLD" * 4000 # ~12000 chars
addition = "newest"
result = _append_capped(big_existing, addition)
assert "[...earlier notes truncated for size...]" in result
assert result.endswith(addition)
def test_append_capped_no_truncation_below_max() -> None:
out = _append_capped("hello", "world")
assert out == "hello\n\nworld"
def test_default_claim_statuses_for_qa() -> None:
"""QA role returns the role-specific set."""
statuses = _default_claim_statuses("qa")
assert TaskStatus.AWAITING_QA in statuses
def test_default_claim_statuses_for_unknown_role() -> None:
statuses = _default_claim_statuses("developer")
assert TaskStatus.PENDING in statuses
assert TaskStatus.NEEDS_REVISION in statuses
def test_default_claim_statuses_for_none() -> None:
statuses = _default_claim_statuses(None)
assert statuses == {TaskStatus.PENDING}
def test_extract_original_developer_invalid_format() -> None:
"""Invalid UUID format returns None even when the marker is present."""
task = SimpleNamespace(orchestration_markers={"original_developer": "not-a-uuid"})
assert extract_original_developer(task) is None
def test_extract_original_developer_no_match() -> None:
task = SimpleNamespace(orchestration_markers={"documenter": "x"})
assert extract_original_developer(task) is None
def test_extract_original_developer_empty() -> None:
assert (
extract_original_developer(SimpleNamespace(orchestration_markers=None)) is None
)
assert extract_original_developer(SimpleNamespace(orchestration_markers={})) is None
def test_extract_original_developer_valid() -> None:
test_uuid = "12345678-1234-1234-1234-123456789012"
task = SimpleNamespace(orchestration_markers={"original_developer": test_uuid})
assert extract_original_developer(task) == test_uuid
# ---------------------------------------------------------------------------
# _validate_parent_depth
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_validate_parent_depth_missing_parent_raises(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
with pytest.raises(ValueError, match="not found"):
await svc._validate_parent_depth(uuid4())
@pytest.mark.asyncio
async def test_validate_parent_depth_circular_reference(
task_setup: dict,
db_session: AsyncSession,
) -> None:
"""A self-referential parent loop raises ValueError."""
svc = task_setup["svc"]
a = await svc.create(_req(task_setup))
b = await svc.create(_req(task_setup, parent_task_id=a.id))
# Force circular: a.parent_task_id = b.id
a.parent_task_id = b.id
await db_session.flush()
with pytest.raises(ValueError, match="Circular reference"):
await svc._validate_parent_depth(a.id)
@pytest.mark.asyncio
async def test_validate_parent_depth_exceeds_max(
task_setup: dict,
) -> None:
"""Adding a child past MAX_TASK_DEPTH raises ValueError."""
svc = task_setup["svc"]
# Build a chain of MAX_TASK_DEPTH+1 tasks
parent = None
for _ in range(MAX_TASK_DEPTH):
new = await svc.create(
_req(task_setup, parent_task_id=parent.id if parent else None)
)
parent = new
assert parent is not None
with pytest.raises(ValueError, match="MAX_TASK_DEPTH"):
await svc._validate_parent_depth(parent.id)
# ---------------------------------------------------------------------------
# activate without project_id
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_activate_without_project_or_product_raises(
task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A task with neither a project nor a product cannot be activated.
A task needs a project (a repo) OR a product (a cell->project map for a
fan-out/coordination task). Only when BOTH are missing is it genuinely
misconfigured and activation must raise. Stub get() to return a task with
project_id=None AND product_id=None so the guard is reached.
"""
svc = task_setup["svc"]
fake_task = MagicMock()
fake_task.id = uuid4()
fake_task.title = "fake"
fake_task.status = TaskStatus.BACKLOG
fake_task.project_id = None
fake_task.product_id = None
async def _stub_get(tid: Any) -> Any:
del tid
return fake_task
monkeypatch.setattr(svc, "get", _stub_get)
# Stub session.execute to return a session_link so we get past that gate
fake_link = MagicMock()
fake_result = MagicMock()
fake_result.scalar_one_or_none.return_value = fake_link
db = task_setup["db"]
real_execute = db.execute
async def _exec_stub(stmt: Any, *a: Any, **kw: Any) -> Any:
compiled = str(stmt)
if "session_tasks" in compiled.lower():
return fake_result
return await real_execute(stmt, *a, **kw)
monkeypatch.setattr(db, "execute", _exec_stub)
with pytest.raises(ValueError, match="no project or product"):
await svc.activate(fake_task.id, agent_role="cell_pm")
# ---------------------------------------------------------------------------
# _ensure_branch_for_task / _auto_create_branch / _resolve_parent_branch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ensure_branch_returns_existing(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.branch_name = "feature/backend/EXISTING"
out = await svc._ensure_branch_for_task(task, task_setup["agent_id"])
assert out == "feature/backend/EXISTING"
@pytest.mark.asyncio
async def test_ensure_branch_no_project_id_raises(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
# Cleared project_id via raw SQL won't work here — instead, mock task.project_id
task.project_id = None
with pytest.raises(ValueError, match="project_id"):
await svc._ensure_branch_for_task(task, task_setup["agent_id"])
@pytest.mark.asyncio
async def test_ensure_branch_batch_umbrella_returns_empty(
task_setup: dict,
) -> None:
"""A MegaTask umbrella (batch_id, top-level, no project/product) is branchless
by design — it must short-circuit to "" rather than hit the misconfigured
raise, so the claim path treats it as coordination, not a defect."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.project_id = None
task.product_id = None
task.batch_id = uuid4()
task.parent_task_id = None
out = await svc._ensure_branch_for_task(task, task_setup["agent_id"])
assert out == ""
@pytest.mark.asyncio
async def test_create_denies_stray_batch_id_on_targeted_top_level_task(
task_setup: dict,
) -> None:
"""Guardrail: a top-level task that targets a project must NOT carry a
batch_id — that umbrella-shaped-but-targeted task would otherwise spoof the
branchless exemption. create() refuses it."""
svc = task_setup["svc"]
# _req sets project_id; adding batch_id with no parent is the spoof shape.
with pytest.raises(ValueError, match="batch_id is only valid"):
await svc.create(_req(task_setup, batch_id=uuid4()))
@pytest.mark.asyncio
async def test_create_denies_batch_root_subtask_under_non_umbrella_parent(
task_setup: dict,
) -> None:
"""Guardrail: a batch root-subtask's parent must be the batch umbrella. A
child pointed at a normal (non-umbrella) parent, or a mismatched batch, is
refused."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup)) # a normal task, no batch_id
with pytest.raises(ValueError, match="parent must be the batch umbrella"):
await svc.create(_req(task_setup, batch_id=uuid4(), parent_task_id=parent.id))
@pytest.mark.asyncio
async def test_update_rejects_breaking_batch_umbrella_shape(
task_setup: dict,
) -> None:
"""Guardrail completeness: update() re-validates the MegaTask shape, so a
PATCH that adds a project to a branchless umbrella (which would spoof the
branch-gate / no-PR exemption) is refused — the invariant the branchless
predicates trust is enforced on mutation, not only at create."""
db = task_setup["db"]
svc = task_setup["svc"]
umbrella = TaskTable(
id=uuid4(),
title="MegaTask",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=1,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=None, # a valid umbrella targets neither
product_id=None,
batch_id=uuid4(),
parent_task_id=None,
team=Team.MAIN_PM,
created_by=task_setup["agent_id"],
)
db.add(umbrella)
await db.flush()
with pytest.raises(ValueError, match="MegaTask shape"):
await svc.update(umbrella.id, project_id=task_setup["project_id"])
@pytest.mark.asyncio
async def test_auto_create_branch_no_project_raises(
task_setup: dict,
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
fake_project_svc = MagicMock()
fake_project_svc.get = AsyncMock(return_value=None)
monkeypatch.setattr(
"roboco.services.project.get_project_service",
lambda _s: fake_project_svc,
)
monkeypatch.setattr(
"roboco.services.git.get_git_service",
lambda _s: MagicMock(),
)
with pytest.raises(ValueError, match="not found"):
await svc._auto_create_branch(task, task_setup["agent_id"])
@pytest.mark.asyncio
async def test_auto_create_branch_succeeds(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Full happy path of _auto_create_branch."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
await db_session.flush()
fake_project = MagicMock()
fake_project.id = task_setup["project_id"]
fake_project.slug = task_setup["project_slug"]
fake_project.default_branch = "main"
fake_project.assigned_cell = Team.BACKEND
fake_project_svc = MagicMock()
fake_project_svc.get = AsyncMock(return_value=fake_project)
fake_git = MagicMock()
fake_git.get_workspace = AsyncMock(return_value="/workspace")
fake_git.create_branch = AsyncMock(return_value=("feature/backend/AAAAAAA", None))
monkeypatch.setattr(
"roboco.services.project.get_project_service",
lambda _s: fake_project_svc,
)
monkeypatch.setattr("roboco.services.git.get_git_service", lambda _s: fake_git)
out = await svc._auto_create_branch(task, task_setup["agent_id"])
assert out == "feature/backend/AAAAAAA"
assert task.branch_name == "feature/backend/AAAAAAA"
@pytest.mark.asyncio
async def test_find_ancestor_branch_walks_up(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
grand = await svc.create(_req(task_setup))
grand.branch_name = "feature/backend/GRAND"
parent = await svc.create(_req(task_setup, parent_task_id=grand.id))
# Parent has no branch_name
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
out = await svc._find_ancestor_branch(child)
assert out == "feature/backend/GRAND"
@pytest.mark.asyncio
async def test_find_ancestor_branch_handles_circular(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Circular reference returns None gracefully (just logs warning)."""
svc = task_setup["svc"]
a = await svc.create(_req(task_setup))
b = await svc.create(_req(task_setup, parent_task_id=a.id))
a.parent_task_id = b.id
await db_session.flush()
out = await svc._find_ancestor_branch(b)
assert out is None
@pytest.mark.asyncio
async def test_find_ancestor_branch_returns_none_when_no_branches(
task_setup: dict, db_session: AsyncSession
) -> None:
"""When parent chain has no branches anywhere, returns None."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
out = await svc._find_ancestor_branch(child)
assert out is None
@pytest.mark.asyncio
async def test_resolve_parent_branch_uses_default_when_no_ancestor(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
fake_project = MagicMock()
fake_project.default_branch = "main"
out = await svc._resolve_parent_branch(task, fake_project)
assert out == "main"
@pytest.mark.asyncio
async def test_resolve_parent_branch_uses_ancestor(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
parent.branch_name = "feature/backend/PARENT"
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
fake_project = MagicMock()
fake_project.default_branch = "main"
out = await svc._resolve_parent_branch(child, fake_project)
assert out == "feature/backend/PARENT"
# ---------------------------------------------------------------------------
# _resolve_team_dir branches
# ---------------------------------------------------------------------------
def test_resolve_team_dir_fullstack(task_setup: dict) -> None:
svc = task_setup["svc"]
fake_project = MagicMock()
fake_project.assigned_cell = Team.FULLSTACK
fake_project.slug = "p"
task = MagicMock()
task.team = Team.BACKEND
out = svc._resolve_team_dir(fake_project, task)
assert "p/backend" in out
def test_resolve_team_dir_fullstack_no_team(task_setup: dict) -> None:
svc = task_setup["svc"]
fake_project = MagicMock()
fake_project.assigned_cell = Team.FULLSTACK
fake_project.slug = "p"
task = MagicMock()
task.team = None
out = svc._resolve_team_dir(fake_project, task)
assert "p/cross" in out
def test_resolve_team_dir_task_team(task_setup: dict) -> None:
svc = task_setup["svc"]
fake_project = MagicMock()
fake_project.assigned_cell = Team.BACKEND
task = MagicMock()
task.team = Team.FRONTEND
out = svc._resolve_team_dir(fake_project, task)
assert out == "frontend"
def test_resolve_team_dir_falls_back_to_project_cell(task_setup: dict) -> None:
svc = task_setup["svc"]
fake_project = MagicMock()
fake_project.assigned_cell = Team.BACKEND
task = MagicMock()
task.team = None
out = svc._resolve_team_dir(fake_project, task)
assert out == "backend"
def test_resolve_team_dir_cross_when_nothing(task_setup: dict) -> None:
svc = task_setup["svc"]
fake_project = MagicMock()
fake_project.assigned_cell = None
task = MagicMock()
task.team = None
out = svc._resolve_team_dir(fake_project, task)
assert out == "cross"
# ---------------------------------------------------------------------------
# _validate_claim_status — role-specific status missing agent
# ---------------------------------------------------------------------------
def test_validate_claim_status_no_agent_role_specific(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.status = TaskStatus.AWAITING_QA
err = svc._validate_claim_status(task, agent=None, valid_statuses=set())
assert err is not None
assert "role required" in err
def test_validate_claim_status_invalid(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.status = TaskStatus.COMPLETED
err = svc._validate_claim_status(
task, agent=None, valid_statuses={TaskStatus.PENDING}
)
assert err == "invalid status for role"
def test_validate_claim_status_ok(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.status = TaskStatus.PENDING
err = svc._validate_claim_status(
task, agent=None, valid_statuses={TaskStatus.PENDING}
)
assert err is None
def test_validate_not_self_review_no_agent(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
out = svc._validate_not_self_review(task, agent=None, agent_id=uuid4())
assert out is None
def test_validate_not_self_review_no_role(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
agent = MagicMock(role=None)
out = svc._validate_not_self_review(task, agent, agent_id=uuid4())
assert out is None
def test_validate_not_self_review_dev_role(task_setup: dict) -> None:
"""Dev role isn't QA/documenter so passes (returns None)."""
svc = task_setup["svc"]
task = MagicMock()
agent = MagicMock(role=AgentRole.DEVELOPER)
out = svc._validate_not_self_review(task, agent, agent_id=uuid4())
assert out is None
def test_validate_not_self_review_qa_self(task_setup: dict) -> None:
svc = task_setup["svc"]
aid = uuid4()
task = MagicMock()
task.orchestration_markers = {"original_developer": str(aid)}
agent = MagicMock(role=AgentRole.QA)
out = svc._validate_not_self_review(task, agent, agent_id=aid)
assert "self-review" in (out or "")
def test_set_original_developer_skips_when_already_set(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.orchestration_markers = {"original_developer": "already-set"}
task.assigned_to = uuid4()
agent = MagicMock(role=AgentRole.QA, id=uuid4())
# An existing original_developer marker must not be overwritten.
before = dict(task.orchestration_markers)
svc._set_original_developer_context(task, agent)
assert task.orchestration_markers == before
def test_set_original_developer_skips_when_no_role(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
agent = MagicMock(role=None)
svc._set_original_developer_context(task, agent)
def test_set_original_developer_skips_when_dev_role(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.quick_context = ""
task.assigned_to = uuid4()
agent = MagicMock(role=AgentRole.DEVELOPER, id=uuid4())
svc._set_original_developer_context(task, agent)
# dev role → early return, quick_context unchanged
assert task.quick_context == ""
def test_set_original_developer_skips_when_self(task_setup: dict) -> None:
svc = task_setup["svc"]
aid = uuid4()
task = MagicMock()
task.quick_context = ""
task.assigned_to = aid
agent = MagicMock(role=AgentRole.QA, id=aid)
svc._set_original_developer_context(task, agent)
# Same agent → don't set
assert task.quick_context == ""
# ---------------------------------------------------------------------------
# unclaim_for_reaper / unclaim_for_agent — TaskLifecycleError catches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unclaim_for_reaper_lifecycle_error_returns(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Force the lifecycle validator to raise TaskLifecycleError → return."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
def _raise(*_args: Any, **_kwargs: Any) -> None:
raise TaskLifecycleError(
current_status="claimed",
target_status="pending",
valid_transitions=[],
)
monkeypatch.setattr(svc, "_validate_and_set_status", _raise)
# Should not raise — silently returns
await svc.unclaim_for_reaper(task.id)
@pytest.mark.asyncio
async def test_unclaim_for_agent_lifecycle_error_returns_none(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
def _raise(*_args: Any, **_kwargs: Any) -> None:
raise TaskLifecycleError(
current_status="claimed",
target_status="pending",
valid_transitions=[],
)
monkeypatch.setattr(svc, "_validate_and_set_status", _raise)
out = await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"])
assert out is None
@pytest.mark.asyncio
async def test_resume_for_agent_lifecycle_error_returns_none(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.PAUSED
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
async def _bad_resume(*_a: Any, **_kw: Any) -> None:
raise TaskLifecycleError(
current_status="paused",
target_status="in_progress",
valid_transitions=[],
)
monkeypatch.setattr(svc, "resume", _bad_resume)
out = await svc.resume_for_agent(task.id, agent_id=task_setup["agent_id"])
assert out is None
# ---------------------------------------------------------------------------
# docs_complete edge cases
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_docs_complete_indexes_when_documents_present(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Path: docs_complete spawns _index_docs_background when docs present."""
svc = task_setup["svc"]
doc = AgentTable(
id=uuid4(),
name="Doc",
slug=f"be-doc-{uuid4().hex[:8]}",
role=AgentRole.DOCUMENTER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="d",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(doc)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_DOCUMENTATION
task.assigned_to = doc.id
task.pr_number = 1
task.pr_url = "u"
task.documents = [{"path": "doc1.md"}]
await db_session.flush()
fake_optimal = MagicMock()
fake_optimal.index_documentation = AsyncMock(return_value=1)
async def _get_optimal() -> Any:
return fake_optimal
monkeypatch.setattr("roboco.services.optimal.get_optimal_service", _get_optimal)
out = await svc.docs_complete(task.id, doc_notes="docs done")
assert out is not None
await asyncio.sleep(0.05)
def test_record_doc_notes_skips_empty(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.quick_context = "before"
svc._record_doc_notes(task, None)
assert task.quick_context == "before"
svc._record_doc_notes(task, "")
assert task.quick_context == "before"
def test_record_documenter_context_skips_no_assignee(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.assigned_to = None
task.quick_context = "x"
svc._record_documenter_context(task)
assert task.quick_context == "x"
def test_record_documenter_context_skips_when_already(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.assigned_to = uuid4()
task.orchestration_markers = {"documenter": "something"}
before = dict(task.orchestration_markers)
svc._record_documenter_context(task)
assert task.orchestration_markers == before
def test_record_documenter_context_appends(task_setup: dict) -> None:
svc = task_setup["svc"]
aid = uuid4()
task = MagicMock()
task.assigned_to = aid
task.quick_context = "existing"
task.orchestration_markers = None
svc._record_documenter_context(task)
assert markers.get_documenter(task) == str(aid)
assert task.quick_context == "existing" # human field untouched
def test_record_documenter_context_first_entry(task_setup: dict) -> None:
svc = task_setup["svc"]
aid = uuid4()
task = MagicMock()
task.assigned_to = aid
task.orchestration_markers = None
svc._record_documenter_context(task)
assert markers.get_documenter(task) == str(aid)
def test_record_completion_notes_skips_empty(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.quick_context = "before"
svc._record_completion_notes(task, None)
assert task.quick_context == "before"
def test_record_completion_notes_with_existing_context(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.quick_context = "existing"
task.orchestration_markers = None
svc._record_completion_notes(task, "merged successfully")
# quick_context (the ResumptionNote slot) is left untouched; the note goes
# to a structured marker, not packed in as `completion_notes:<text>` soup.
assert task.quick_context == "existing"
assert markers.get_transition_note(task, "completion") == "merged successfully"
def test_record_completion_notes_no_existing_context(task_setup: dict) -> None:
svc = task_setup["svc"]
task = MagicMock()
task.quick_context = None
task.orchestration_markers = None
svc._record_completion_notes(task, "merged")
assert task.quick_context is None
assert markers.get_transition_note(task, "completion") == "merged"
# ---------------------------------------------------------------------------
# _resolve_pm_for_review — direct candidate found
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_pm_for_review_finds_first_assignee(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
pm_agent = AgentTable(
id=uuid4(),
name="PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(pm_agent)
await db_session.flush()
parent = await svc.create(_req(task_setup, assigned_to=pm_agent.id))
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
pm_id = await svc._resolve_pm_for_review(child)
assert pm_id == pm_agent.id
# ---------------------------------------------------------------------------
# _get_completing_agent_role
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_completing_agent_role_none_for_missing_agent_id(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
out = await svc._get_completing_agent_role(None)
assert out is None
@pytest.mark.asyncio
async def test_get_completing_agent_role_none_for_unknown(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
out = await svc._get_completing_agent_role(uuid4())
assert out is None
@pytest.mark.asyncio
async def test_get_completing_agent_role_returns_role(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
out = await svc._get_completing_agent_role(task_setup["agent_id"])
assert out == "developer"
# ---------------------------------------------------------------------------
# complete: main_pm root parent escalates to CEO
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_complete_main_pm_root_parent_escalates_ceo(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
main_pm = AgentTable(
id=uuid4(),
name="MainPM",
slug=f"main-pm-{uuid4().hex[:8]}",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(main_pm)
await db_session.flush()
parent = await svc.create(_req(task_setup))
parent.status = TaskStatus.AWAITING_PM_REVIEW
parent.pr_number = 1
parent.pr_url = "u"
parent.pr_created = True
parent.docs_complete = True
await db_session.flush()
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
child.status = TaskStatus.COMPLETED
await db_session.flush()
out = await svc.complete(parent.id, agent_id=main_pm.id)
assert out is not None
# Root parent + main_pm + descendants → escalate to CEO
assert out.status == TaskStatus.AWAITING_CEO_APPROVAL
# ---------------------------------------------------------------------------
# complete: PR not merged returns None
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_complete_returns_none_when_pr_not_merged(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Cell PM completing → escalate-to-Main PM is short-circuited so the
PR-not-merged guard is reached. Strip leaked Main PMs from earlier tests
that committed and survived rollback isolation.
"""
svc = task_setup["svc"]
await db_session.execute(
cast("Table", AgentTable.__table__)
.update()
.where(AgentTable.role == AgentRole.MAIN_PM)
.values(role=AgentRole.SYSTEM)
)
pm = AgentTable(
id=uuid4(),
name="PM",
slug=f"be-pm-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(pm)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_PM_REVIEW
ws = WorkSessionTable(
id=uuid4(),
project_id=task_setup["project_id"],
task_id=task.id,
agent_id=task_setup["agent_id"],
branch_name="feature/backend/x",
base_branch="main",
target_branch="main",
status=WorkSessionStatus.ACTIVE,
pr_status="open", # NOT merged
)
db_session.add(ws)
await db_session.flush()
task.work_session_id = ws.id
await db_session.flush()
out = await svc.complete(task.id, agent_id=pm.id)
assert out is None
# ---------------------------------------------------------------------------
# list_by_team_or_assignee — no conditions returns []
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_by_team_or_assignee_no_conditions_empty(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
rows = await svc.list_by_team_or_assignee(team=None, agent_id=None)
assert rows == []
@pytest.mark.asyncio
async def test_list_by_team_or_assignee_with_status(task_setup: dict) -> None:
svc = task_setup["svc"]
rows = await svc.list_by_team_or_assignee(
team=Team.BACKEND, agent_id=None, status=TaskStatus.PENDING
)
assert isinstance(rows, list)
# ---------------------------------------------------------------------------
# escalate_to_ceo_for_agent — inner escalate returns None → ValidationError
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_escalate_to_ceo_for_agent_inner_returns_none(
task_setup: dict,
db_session: AsyncSession,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_PM_REVIEW
task.pr_number = 1
task.pr_url = "u"
task.pr_created = True
task.docs_complete = True
await db_session.flush()
agent_ctx = AgentContext(
agent_id=task_setup["agent_id"],
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
slug="x",
)
class _P:
def can_perform_task_action(self, *a: Any, **kw: Any) -> bool:
del a, kw
return True
# Force the inner escalate_to_ceo to return None
object.__setattr__(svc, "escalate_to_ceo", AsyncMock(return_value=None))
with pytest.raises(ValidationError, match="awaiting_pm_review"):
await svc.escalate_to_ceo_for_agent(
task.id,
agent_ctx,
_P(),
"Substantial reasons for CEO review of breaking changes",
)
# ---------------------------------------------------------------------------
# mark_agent_idle — missing agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mark_agent_idle_missing_agent_no_op(task_setup: dict) -> None:
svc = task_setup["svc"]
# Should not raise
await svc.mark_agent_idle(uuid4())
# ---------------------------------------------------------------------------
# qa_fail — actor mismatch logs warning
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_qa_fail_actor_mismatch_warning(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
qa_a = AgentTable(
id=uuid4(),
name="QA-A",
slug=f"qa-a-{uuid4().hex[:8]}",
role=AgentRole.QA,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="qa",
capabilities=[],
permissions={},
metrics={},
)
qa_b = AgentTable(
id=uuid4(),
name="QA-B",
slug=f"qa-b-{uuid4().hex[:8]}",
role=AgentRole.QA,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="qa",
capabilities=[],
permissions={},
metrics={},
)
db_session.add_all([qa_a, qa_b])
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.claimed_by = qa_a.id # Different from qa_b
await db_session.flush()
out = await svc.qa_fail(qa_b.id, task.id, "needs work", issues=["x"])
assert out is not None # warning logged but flow continues
# ---------------------------------------------------------------------------
# cell_pm_complete — missing task returns None
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_missing_task_returns_none(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
out = await svc.cell_pm_complete(
task_setup["agent_id"], uuid4(), "merged", merge_commit="x"
)
assert out is None
# ---------------------------------------------------------------------------
# get_task_service factory
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_task_service_returns_instance(
db_session: AsyncSession,
) -> None:
out = get_task_service(db_session)
assert isinstance(out, TaskService)
# ---------------------------------------------------------------------------
# Final coverage gaps (line-specific)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ensure_branch_calls_auto_create(
task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cover line 595: _ensure_branch_for_task → _auto_create_branch."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
# No branch_name set → falls through to auto-create
async def _stub(_t: Any, _a: Any) -> str:
return "feature/backend/MOCK"
monkeypatch.setattr(svc, "_auto_create_branch", _stub)
out = await svc._ensure_branch_for_task(task, task_setup["agent_id"])
assert out == "feature/backend/MOCK"
@pytest.mark.asyncio
async def test_find_ancestor_branch_break_when_parent_missing(
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cover line 622: break when parent lookup returns None."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
real_get = svc.get
async def _stub_get(tid: Any) -> Any:
# Return None when looking up the parent
if tid == parent.id:
return None
return await real_get(tid)
monkeypatch.setattr(svc, "get", _stub_get)
out = await svc._find_ancestor_branch(child)
assert out is None
def test_validate_claim_team_no_agent(task_setup: dict) -> None:
"""Cover line 838: _validate_claim_team early return when no agent."""
svc = task_setup["svc"]
task = MagicMock()
task.team = Team.BACKEND
out = svc._validate_claim_team(task, agent=None)
assert out is None
def test_validate_not_self_review_qa_with_different_dev(task_setup: dict) -> None:
"""Cover line 866: _validate_not_self_review returns None when QA differs."""
svc = task_setup["svc"]
task = MagicMock()
task.quick_context = f"original_developer:{uuid4()}"
agent = MagicMock(role=AgentRole.QA)
out = svc._validate_not_self_review(task, agent, agent_id=uuid4())
assert out is None
def test_set_original_developer_records_when_different(task_setup: dict) -> None:
"""Sets the original_developer marker when assigned_to != agent.id."""
svc = task_setup["svc"]
task = MagicMock()
task.orchestration_markers = None
other_id = uuid4()
task.assigned_to = other_id
agent = MagicMock(role=AgentRole.QA, id=uuid4())
svc._set_original_developer_context(task, agent)
assert markers.get_original_developer(task) == str(other_id)
@pytest.mark.asyncio
async def test_finalize_claim_refreshes_after_branch_creation(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Cover line 998: session.refresh after _ensure_branch_for_task succeeds."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
await db_session.flush()
async def _ensure_branch(_t: Any, _a: Any) -> str:
_t.branch_name = "feature/backend/X"
return "feature/backend/X"
monkeypatch.setattr(svc, "_ensure_branch_for_task", _ensure_branch)
refresh_mock = AsyncMock()
monkeypatch.setattr(svc.session, "refresh", refresh_mock)
claimed = await svc.claim(task.id, task_setup["agent_id"])
assert claimed is not None
refresh_mock.assert_awaited()
@pytest.mark.asyncio
async def test_resolve_pm_for_review_returns_none_when_chain_broken(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Cover line 2650: parent lookup returns None mid-chain."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
real_get = svc.get
async def _stub_get(tid: Any) -> Any:
# Return None when looking up the parent
if tid == parent.id:
return None
return await real_get(tid)
monkeypatch.setattr(svc, "get", _stub_get)
out = await svc._resolve_pm_for_review(child)
assert out is None
@pytest.mark.asyncio
async def test_unclaim_for_agent_works_with_uuid_round_trip(
task_setup: dict,
db_session: AsyncSession,
) -> None:
"""Regression for 2026-05-08: main-pm got 'not your claim' on its OWN
claim. Pin the UUID-comparator behavior: same agent_id in (whether
fresh UUID or string-coerced UUID round-trip), unclaim succeeds.
"""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
# Round-trip the UUID through string → UUID to mirror what an HTTP
# request body / header would do via Pydantic. The comparator must
# treat both the freshly-constructed and the round-tripped UUID as
# equal (which Python's UUID class does — pinning so a future move
# to a custom comparator can't silently break it).
same_uuid_via_str = UUID(str(task_setup["agent_id"]))
out = await svc.unclaim_for_agent(task.id, agent_id=same_uuid_via_str)
assert out is not None, "round-tripped UUID rejected — comparator broken"
assert out.assigned_to is None
assert out.status == TaskStatus.PENDING
@pytest.mark.asyncio
async def test_resume_for_agent_works_with_uuid_round_trip(
task_setup: dict,
db_session: AsyncSession,
) -> None:
"""Mirror of the unclaim regression for resume_for_agent."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.PAUSED
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
same_uuid_via_str = UUID(str(task_setup["agent_id"]))
out = await svc.resume_for_agent(task.id, agent_id=same_uuid_via_str)
assert out is not None, "round-tripped UUID rejected — comparator broken"
assert out.status == TaskStatus.IN_PROGRESS