Files
roboco/tests/integration/test_task_service_transitions.py
T
b9082a5c70 Fix: agent idle deadlock and lifecycle hardening (#96)
* fix(panel): cap dialog height and pin footer so actions stay reachable

Shared DialogContent now caps at max-h-[85vh] with overflow-y-auto, and the
footer is sticky to the bottom. Long content (e.g. a pasted change-request
note) no longer pushes the submit/cancel buttons past the viewport — the body
scrolls while the actions stay visible. No-op on dialogs that already fit.

* feat(notifications): suppress duplicate same-purpose notifications at send

A notification is not created when an unacknowledged one with the same purpose
— same sender, same type, same task, overlapping recipients — already exists.
Body text is not compared, so rewording cannot defeat it; a different type,
task, sender, or an already-acked recipient all still send through. Stops
agents that loop re-issuing the same signal from piling up unread that
soft-blocks the recipient's idle path.

* fix(gateway): stop board/PM lifecycle verbs from 500-crashing

Two unguarded crashes that wedged the org in respawn/escalate loops:

- escalate_to_ceo dereferenced None.status when the verb runner declined the
  escalation (task not in awaiting_pm_review — e.g. a board agent escalating a
  blocked task). It now returns a clean invalid_state. The message/remediate
  build moved to a helper so the function stays within the complexity gate.

- The coordination-root git ops (pr_target, pr_merge, PR update, branch-token
  resolve) called UUID(str(task.project_id)) directly, which raised on a
  coordination/integration task (project_id is None — 'badly formed hexadecimal
  UUID string'). They now resolve through _project_for_task, which falls back to
  the product's repo for project-less roots.

* refactor(intake): split out _block_to_chunk per-block classifier

Extract the per-block classification from _blocks_to_chunks so each function
stays within the xenon cyclomatic-complexity gate (was rank C). Behaviour is
unchanged — verified by the existing intake_driver tests.

* feat(gateway): make the i_am_idle unread soft-block satisfiable

The soft-block on unread A2A / @mentions had no clearing path, so once those
briefing fields populated an agent could never idle — a whole-org deadlock.
Keep the guard (it is correct) and add the missing clear paths:

- New read_messages content verb (schema -> route -> handler ->
  a2a.mark_all_read -> MCP tool -> role do_tools): bulk-zeroes the caller's
  unread A2A and stamps read_at. The idle hint now points to it.
- list_unread_mentions returns UNACKED MENTION-type notifications (each @mention
  already raises one via messaging._notify_mentions) instead of raw,
  unconditional mentions, so they clear via the existing notify_ack. No schema
  migration needed.

The soft-block is now satisfiable: A2A via read_messages, mentions and
notifications via notify_ack.

* fix(tests): repair notification-dedup db.scalar mocks + prompter agent seeding

The notification send-dedup added a db.scalar() purpose-lookup to
_create_notification; the two hand-rolled _FakeDb test stubs (test_notification,
test_a2a_priority_tristate) had no scalar() method → AttributeError. Add
scalar() returning None (no duplicate) so creation proceeds.

Separately, the prompter '& Start' route tests assign the draft to a fixed
product-owner / main-pm AGENT_UUID but only seeded system + CEO, so the
assigned_to FK failed in isolation (and main-pm flaked in the full suite). Seed
both via idempotent merge() in _seed_project_and_ceo.

* fix(git): gitignore .pnpm-store + flag GH001 push rejection as permanent

A dev once committed the ~115 MB pnpm store → GitHub GH001 (>100 MB) pre-receive
reject → open_pr retry-loop. Two root fixes:
- Add .pnpm-store/ to .gitignore — an ignored dir can't be staged by any git add.
- push() restates a GH001 / file-size rejection as an unmistakable PERMANENT
  error pointing at i_am_blocked, so the agent stops blind-retrying a push that
  can never succeed (it otherwise mis-reads the raw output as a transient timeout).
  The per-verb retry cap (open_pr: 5) already bounded the burn; this ends it.

* fix(gateway): accept a PM decision note as satisfying the complete/submit_up reflect gate

A cell/main PM that wrote a fresh decision but no separate reflect note bounced
on the reflect tracing-gate indefinitely (re-confirmed live: cell PMs looped on
cell_pm_complete -> journal:reflect until reaped, burning tokens — worse because
each respawn resets the per-verb retry cap). For a PM closing/submitting a task
the decision note already documents the close; the separate reflect is the
redundant artifact weak-model PMs forget. Accept a fresh decision as satisfying
reflect for complete + submit_up — the gate still requires a decision +
substantive notes, so the close stays documented.

NOTE (enforcement tradeoff, flagged for CEO review): this intentionally relaxes
the PM complete/submit_up gate. It does NOT touch the developer i_am_done gate.

* feat(gateway): refuse i_am_idle when a PM still owns a task awaiting its review

A cell/main PM once tried to 'send work back' by DMing the developer and going
idle — but a DM changes no task state, so the task stayed awaiting_pm_review and
the orchestrator just re-dispatched the PM in a loop. i_am_idle now refuses (like
the pending-assignment guard) when a PM owns an awaiting_pm_review task, with a
clear remediation: complete() to finish, or reassign()/delegate() to route it
back. PM-only; devs/QA/doc unaffected. Pairs with the reflect-gate relaxation so
the PM can actually complete instead of looping.

* feat(gateway): push a prior-work handoff digest into task-scoped briefings

A freshly spawned or respawned agent previously started cold on every
lifecycle hand-off: the prior worker's PR, commits, acceptance status and
journal highlights lived in task evidence but were pull-on-demand, so each
new role agent re-explored the codebase from scratch — wasted tokens and
fragile context loss across respawns.

build_task_handoff() composes a compact, DB-only digest (no git diff) and
_briefing_for() now attaches it to context_briefing whenever the caller
already holds the task row. The digest is built only from a passed-in task,
so there are zero extra fetches: every resumption entry point (give_me_work
and pm_give_me_work, i_will_work_on, i_will_plan, triage/triage_all,
i_am_done, submit_up, escalate_up, complete) threads the loaded task, while
id-only correction/rejection paths cleanly omit it.

Every field is type-guarded so a partial row never leaks a non-serialisable
value into the envelope.

* docs(prompts): tell agents to resume from the briefing handoff before re-exploring

The base prompt described the success envelope but never told agents to act
on context_briefing, so a respawned or hand-off agent would re-scan the
whole repo and re-derive the plan even when the briefing already carried the
prior worker's PR, commits, acceptance status and journal highlights.

Adds a 'Resume from your briefing' section that walks each task_handoff
field and instructs the agent to continue from it — and to read the unread
A2A / mention / notification lists, which are messages addressed to them.

Pairs with the gateway change that now pushes task_handoff into every
task-scoped briefing.

* feat(tasks): remember cleared dependencies so the unblock briefing can surface them

When an upstream dependency completed, _unblock_dependents removed its id from
the dependent's dependency_ids to let it be claimed — destroying the only
record of which upstream task had just landed. The revived dependent then
re-discovered that work from cold.

Adds tasks.completed_dependency_ids (Alembic 026, uuid[] default '{}'):
_unblock_dependents now appends the cleared id there instead of only dropping
it, and the briefing handoff digest surfaces it so the agent picking the task
back up knows its blocker cleared because that upstream work shipped. The base
prompt documents the field.

Migration round-trip verified against postgres (upgrade adds the column,
downgrade drops it).

* docs(prompts): instruct PMs to split oversized tasks into per-concern subtasks

A subtask carrying a long acceptance list or spanning multiple layers/files
drove repeated QA failures and a PM revision loop — QA can't pass a partial,
and the dev keeps re-touching unrelated parts. Nothing in the PM prompts told
them to decompose by size/concern.

cell_pm gets a 'Sizing' rule: one subtask = one focused concern with ~2-4
criteria and its own dev->QA pass; decompose anything larger before
delegating, sequencing with dependencies. main_pm gets a matching reminder to
scope each cell's slice to that cell's layer rather than handing a cell a
cross-layer monolith that just pushes the problem down a level.

* fix(gateway): mirror the task= kwarg on ChoreographerHelpers helper signatures

The handoff-digest change added a keyword-only task= parameter to
_briefing_for and _build_tracing_gap in _impl, but the ChoreographerHelpers
base that the role mixins inherit still declared the old signatures, so the
composed Choreographer had two incompatible base definitions (mypy [misc]).
Sync the base declarations to match.

* fix(tasks): keep the owner on a substitute-out so the task isn't orphaned

build_substitute_update unconditionally nulled assigned_to, so any
substitute that routes to PENDING (max_retries, low_context, out_of_scope_*)
— the path a verb hitting repeated 500s or its retry limit takes — left the
task pending AND unassigned. The dispatcher only respawns a pending task when
it has an owner, so the task went dormant: no agent ever picked it back up.

Keep the task with its current owner instead. A substitute-out is almost
always a transient stall, so the task re-dispatches to the SAME agent, which
resumes from the briefing handoff. Only the task_complete -> PM-review handoff
changes owner (unchanged).

* feat(a2a): suppress duplicate unread A2A messages at send

A respawned or retrying agent could re-emit the same DM, stacking identical
copies on the recipient's inbox and re-bumping the unread count — noise that
the recipient then has to clear. The notification path already dedups; A2A did
not.

send_chat_message now suppresses a send when an identical message from the
same sender is still unread in the conversation, keyed on (conversation,
sender, message_kind, content). Genuinely different messages are never
collapsed (verified: distinct content still produces distinct rows), so this
avoids the earlier per-pair over-suppression. No migration.

* fix(panel): default the notifications view to Unread, not All

Landing on the All tab buried new notifications under everything already
seen — the most-reported annoyance. The Unread tab is the actionable view, so
make it the default; the All/Pending tabs are one click away.

* fix(panel): show clone progress during intake prep instead of a frozen pill

The first clone of a repo can take a few minutes, during which the intake
form showed only a static 'Preparing the agent…' button — indistinguishable
from a hang. Add a progress region while preparing: an elapsed timer, a
saturating progress bar (approaches but never reaches 100% until the agent
actually answers), and staged copy (spinning up → cloning → first-clone-takes-
a-while → reading the codebase) so the wait reads as work, not a freeze.

* feat(docs): index workspace-authored docs that never reached the RAG store

Docs written through roboco_docs_write land at /app/docs on the orchestrator
and index fine. But a documenter can also write docs with Edit/Write directly
in its own clone (README, CHANGELOG, workspace markdown); those resolve to a
/app/docs path that doesn't exist on the orchestrator, so the indexer reads
nothing and the docs never become searchable — a cross-container miss with no
shared mount to bridge it.

On docs completion, capture each listed doc's committed content out of the
branch (new GitService.read_file_at_branch, via git show) and write it
server-side under /app/docs before indexing, so workspace-authored docs reach
RAG too. Docs already present server-side are skipped; absolute paths and
unreadable/uncommitted files are passed over best-effort.

* feat(prompter): survive a browser reload by reconnecting to the live intake chat

The intake chat lived entirely in React state, so a page reload wiped it and
dropped the human back to the scope form — even though the agent container
outlives the page. Now the chat persists a small TTL'd slice (session id,
messages, scope, draft) to localStorage and, on mount, reconnects: it asks the
new GET /live/{id}/status whether the session is still running and, if so,
restores the history and reopens the SSE stream; if dead or expired it clears
and shows the form. A full reload doesn't run React effect cleanup, so the
navigate-away reap never fires on refresh and the session stays up.

Backend adds the status endpoint + PrompterLiveRegistry.is_alive; localStorage
is cleared on confirm, start-another, and SPA navigate-away.

* chore: remove internal session-bookkeeping refs from code comments (part 1)

Strip leaked task/finding numbers, Wave/Phase/cluster/audit labels from
docstrings and comments across services, foundation policy, runtime, mcp,
api schemas, and agent_sdk — they mean nothing to a repo reader and expose
process internals. Wording preserved; only the labels dropped. Done by hand,
one comment at a time (no scripted rewrite). _impl.py follows separately.

* chore: remove internal session-bookkeeping refs from code comments (part 2)

Finishes the manual scrub: the choreographer _impl.py docstrings/comments plus
the remaining dogfood-run ('smoke-N') labels across runtime, mcp, foundation,
api schemas, services, and agent factories. Reworded to describe the bug or
behaviour in plain words; every label dropped. The repo source is now free of
task/finding numbers, Wave/Phase/cluster/audit/smoke labels. By hand, one
comment at a time.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-10 12:04:24 +02:00

2075 lines
66 KiB
Python

"""TaskService coverage — status transitions + claim/unclaim/cancel paths.
Covers the core lifecycle methods (start, claim, unclaim variants, pause,
resume, cancel cascades, block/unblock, fail_qa with original-developer
reassignment, ceo_approve/ceo_reject, escalation chains).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import (
AgentTable,
JournalEntryTable,
ProductTable,
ProjectTable,
)
from roboco.events import EventType
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import (
BlockerResolverType,
Complexity,
JournalEntryType,
TaskNature,
TaskStatus,
TaskType,
)
from roboco.models.task import TaskCreateRequest
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.base import NotFoundError
from roboco.services.task import SoftBlockInfo, TaskService
from sqlalchemy import select
if TYPE_CHECKING:
from collections.abc import AsyncIterator
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="T-Proj",
slug=f"t-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()
yield {
"svc": TaskService(db_session),
"agent_id": agent.id,
"project_id": project.id,
"db": db_session,
}
def _req(setup: dict, **overrides) -> 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,
)
# ---------------------------------------------------------------------------
# start()
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_returns_none_for_missing(task_setup: dict) -> None:
svc = task_setup["svc"]
assert await svc.start(uuid4()) is None
@pytest.mark.asyncio
async def test_start_returns_none_when_invalid_status(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
assert await svc.start(task.id, agent_role="developer") is None
@pytest.mark.asyncio
async def test_start_returns_none_when_no_plan(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.plan = None
await db_session.flush()
assert await svc.start(task.id, agent_role="developer") is None
@pytest.mark.asyncio
async def test_start_with_plan_advances_to_in_progress(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.assigned_to = task_setup["agent_id"]
task.branch_name = "feature/backend/AAAAAAA"
task.plan = {"text": "step 1"}
await db_session.flush()
started = await svc.start(
task.id, agent_id=task_setup["agent_id"], agent_role="developer"
)
assert started is not None
assert started.status == TaskStatus.IN_PROGRESS
assert started.started_at is not None
@pytest.mark.asyncio
async def test_start_returns_none_when_ownership_fails(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Non-assignee agent cannot start the task."""
svc = task_setup["svc"]
other = AgentTable(
id=uuid4(),
name="Other",
slug=f"other-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(other)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.assigned_to = other.id
task.plan = {"text": "p"}
await db_session.flush()
out = await svc.start(
task.id, agent_id=task_setup["agent_id"], agent_role="developer"
)
assert out is None
@pytest.mark.asyncio
async def test_start_paused_task_resumes_in_progress(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.PAUSED
task.assigned_to = task_setup["agent_id"]
task.plan = {"text": "p"}
await db_session.flush()
started = await svc.start(task.id, agent_role="developer")
assert started is not None
assert started.status == TaskStatus.IN_PROGRESS
# ---------------------------------------------------------------------------
# unclaim_for_reaper
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unclaim_for_reaper_resets_claimed_task(
task_setup: dict, db_session: AsyncSession
) -> 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()
await svc.unclaim_for_reaper(task.id)
refreshed = await svc.get(task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.PENDING
assert refreshed.assigned_to is None
@pytest.mark.asyncio
async def test_unclaim_for_reaper_skips_when_status_already_pending(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
# Should not raise — branch returns immediately.
await svc.unclaim_for_reaper(task.id)
# ---------------------------------------------------------------------------
# unclaim_for_agent — all error paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unclaim_for_agent_returns_none_when_wrong_assignee(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
other = AgentTable(
id=uuid4(),
name="Other",
slug=f"other-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(other)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.assigned_to = other.id
await db_session.flush()
assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
@pytest.mark.asyncio
async def test_unclaim_for_agent_releases_pending_assignment(
task_setup: dict, db_session: AsyncSession
) -> None:
"""#176: an agent assigned a pending task it never claimed must be
able to unclaim it (escape the pending-assigned trap). The row stays
pending; only the assignment is released so the dispatcher can
reassign it instead of orphaning the task + looping the agent."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
# status is PENDING, never claimed — the smoke-17 trap scenario.
out = await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"])
assert out is not None
assert out.status == TaskStatus.PENDING
assert out.assigned_to is None
assert out.active_claimant_id is None
@pytest.mark.asyncio
async def test_unclaim_for_agent_returns_none_when_paused(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A non-pending/claimed/in_progress status (e.g. paused — which has
`resume`, not `unclaim`) is still not unclaimable."""
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()
assert await svc.unclaim_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
# ---------------------------------------------------------------------------
# resume_for_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resume_for_agent_returns_none_when_wrong_assignee(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
other = AgentTable(
id=uuid4(),
name="Other",
slug=f"other-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(other)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.PAUSED
task.assigned_to = other.id
await db_session.flush()
assert await svc.resume_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
@pytest.mark.asyncio
async def test_resume_for_agent_returns_none_when_not_paused(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
assert await svc.resume_for_agent(task.id, agent_id=task_setup["agent_id"]) is None
@pytest.mark.asyncio
async def test_resume_for_agent_advances_to_in_progress(
task_setup: dict, db_session: AsyncSession
) -> 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()
out = await svc.resume_for_agent(task.id, agent_id=task_setup["agent_id"])
assert out is not None
assert out.status == TaskStatus.IN_PROGRESS
# ---------------------------------------------------------------------------
# block / unblock with task-dependency blocker
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_block_returns_none_for_missing(task_setup: dict) -> None:
svc = task_setup["svc"]
blocker = await svc.create(_req(task_setup))
assert await svc.block(uuid4(), blocker_task_id=blocker.id) is None
@pytest.mark.asyncio
async def test_block_adds_to_dependencies(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
blocker = await svc.create(_req(task_setup))
blocked = await svc.block(task.id, blocker_task_id=blocker.id)
assert blocked is not None
assert blocked.status == TaskStatus.BLOCKED
assert blocker.id in blocked.dependency_ids
assert blocked.blocker_resolver_type == BlockerResolverType.AGENT
@pytest.mark.asyncio
async def test_block_does_not_duplicate_existing_dep(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
blocker = await svc.create(_req(task_setup))
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = task_setup["agent_id"]
task.dependency_ids = [blocker.id]
await db_session.flush()
blocked = await svc.block(task.id, blocker_task_id=blocker.id)
assert blocked is not None
# Still single occurrence
assert blocked.dependency_ids.count(blocker.id) == 1
# ---------------------------------------------------------------------------
# soft_block — full happy path including HUMAN resolver type
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_soft_block_with_human_resolver(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
blocked = await svc.soft_block(
task.id,
SoftBlockInfo(
reason="HITL needed",
blocker_type="question",
what_needed="answer",
resolver_type=BlockerResolverType.HUMAN,
),
)
assert blocked is not None
assert blocked.status == TaskStatus.BLOCKED
assert blocked.blocker_resolver_type == BlockerResolverType.HUMAN
@pytest.mark.asyncio
async def test_soft_block_returns_none_when_not_in_progress(
task_setup: dict,
) -> None:
"""soft_block requires IN_PROGRESS — PENDING task fails the gate."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
out = await svc.soft_block(
task.id,
SoftBlockInfo(reason="x", blocker_type="ext", what_needed="y"),
)
assert out is None
# ---------------------------------------------------------------------------
# unblock — when not BLOCKED returns None
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unblock_returns_none_when_not_blocked(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
await db_session.flush()
assert await svc.unblock(task.id) is None
# ---------------------------------------------------------------------------
# pause/resume happy paths verifying side-effects
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pause_in_progress_works(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
await db_session.flush()
out = await svc.pause(task.id)
assert out is not None
assert out.status == TaskStatus.PAUSED
@pytest.mark.asyncio
async def test_resume_paused_works(task_setup: dict, db_session: AsyncSession) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.PAUSED
await db_session.flush()
out = await svc.resume(task.id)
assert out is not None
assert out.status == TaskStatus.IN_PROGRESS
# ---------------------------------------------------------------------------
# fail_qa: original developer reassignment & no-original-developer fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fail_qa_reassigns_to_original_developer(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
dev_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.quick_context = f"original_developer:{dev_id}"
await db_session.flush()
failed = await svc.fail_qa(task.id, notes="missing tests")
assert failed is not None
assert failed.status == TaskStatus.NEEDS_REVISION
assert failed.assigned_to == dev_id
@pytest.mark.asyncio
async def test_fail_qa_with_no_original_dev_unassigns(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
# No quick_context — extract_original_developer returns None
await db_session.flush()
failed = await svc.fail_qa(task.id, notes="needs more")
assert failed is not None
assert failed.assigned_to is None
# ---------------------------------------------------------------------------
# ceo_approve / ceo_reject — happy paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ceo_approve_returns_none_when_wrong_status(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
assert await svc.ceo_approve(task.id) is None
@pytest.mark.asyncio
async def test_ceo_approve_marks_completed(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_CEO_APPROVAL
task.pr_number = 1
task.pr_url = "u"
task.docs_complete = True
task.pr_created = True
await db_session.flush()
approved = await svc.ceo_approve(task.id, notes="approved")
assert approved is not None
assert approved.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_ceo_reject_returns_none_when_wrong_status(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
assert await svc.ceo_reject(task.id, reason="not aligned") is None
@pytest.mark.asyncio
async def test_ceo_reject_reassigns_to_original_dev(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
dev_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_CEO_APPROVAL
task.quick_context = f"original_developer:{dev_id}"
await db_session.flush()
rejected = await svc.ceo_reject(task.id, reason="re-do auth flow")
assert rejected is not None
assert rejected.status == TaskStatus.NEEDS_REVISION
assert rejected.assigned_to == dev_id
@pytest.mark.asyncio
async def test_ceo_reject_clears_assignment_when_no_original_dev(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_CEO_APPROVAL
task.assigned_to = task_setup["agent_id"]
# No quick_context — original_dev resolves None
await db_session.flush()
rejected = await svc.ceo_reject(task.id, reason="redo")
assert rejected is not None
assert rejected.assigned_to is None
@pytest.mark.asyncio
async def test_ceo_reject_routes_coordination_task_to_main_pm(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A rejected coordination/integration root goes to the Main PM to delegate,
not back to a developer."""
svc = task_setup["svc"]
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
if await db_session.get(AgentTable, main_pm_id) is None:
db_session.add(
AgentTable(
id=main_pm_id,
name="Main PM",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
)
product = ProductTable(
name="P", slug=f"p-{uuid4().hex[:8]}", created_by=task_setup["agent_id"]
)
db_session.add(product)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_CEO_APPROVAL
task.project_id = None # coordination root: no project, has product
task.product_id = product.id
await db_session.flush()
rejected = await svc.ceo_reject(task.id, reason="redo the API contract")
assert rejected is not None
assert rejected.status == TaskStatus.NEEDS_REVISION
assert rejected.team == Team.MAIN_PM
assert rejected.assigned_to == main_pm_id
@pytest.mark.asyncio
async def test_ceo_reject_writes_handoff_journal(
task_setup: dict, db_session: AsyncSession
) -> None:
"""The CEO's reason is recorded as a DECISION_LOG journal entry on the task —
the channel that actually reaches the reworker (quick_context does not)."""
svc = task_setup["svc"]
ceo_id = UUID(AGENT_UUIDS["ceo"])
if await db_session.get(AgentTable, ceo_id) is None:
db_session.add(
AgentTable(
id=ceo_id,
name="CEO",
slug="ceo",
role=AgentRole.CEO,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="ceo",
capabilities=[],
permissions={},
metrics={},
)
)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_CEO_APPROVAL
task.quick_context = f"original_developer:{task_setup['agent_id']}"
await db_session.flush()
reason = "AC9/AC10 totals must include cache tokens"
rejected = await svc.ceo_reject(task.id, reason=reason)
assert rejected is not None
entries = (
(
await db_session.execute(
select(JournalEntryTable).where(
JournalEntryTable.task_id == task.id,
JournalEntryTable.type == JournalEntryType.DECISION_LOG,
)
)
)
.scalars()
.all()
)
assert any(reason in (e.content or "") for e in entries)
# ---------------------------------------------------------------------------
# escalate_to_ceo - all error branches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_escalate_to_ceo_returns_none_when_wrong_status(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
out = await svc.escalate_to_ceo(task.id, agent_role="cell_pm", notes="x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_to_ceo_returns_none_for_subtask(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
sub = await svc.create(_req(task_setup, parent_task_id=parent.id))
sub.status = TaskStatus.AWAITING_PM_REVIEW
sub.pr_number = 1
await db_session.flush()
out = await svc.escalate_to_ceo(sub.id, agent_role="cell_pm", notes="x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_to_ceo_returns_none_when_no_pr(
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 = None
await db_session.flush()
out = await svc.escalate_to_ceo(task.id, agent_role="main_pm", notes="x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_to_ceo_advances_status_with_notes(
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 = 42
task.pr_url = "https://example.com/pr/42"
task.pr_created = True
task.docs_complete = True
await db_session.flush()
escalated = await svc.escalate_to_ceo(
task.id, agent_role="main_pm", notes="needs CEO review for breaking change"
)
assert escalated is not None
assert escalated.status == TaskStatus.AWAITING_CEO_APPROVAL
assert "escalation_notes" in (escalated.quick_context or "")
# ---------------------------------------------------------------------------
# cancel + cascade
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cancel_skips_already_terminal_descendants(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Descendants already in COMPLETED/CANCELLED are skipped, not re-cancelled."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
completed_child = await svc.create(_req(task_setup, parent_task_id=parent.id))
completed_child.status = TaskStatus.COMPLETED
pending_child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
out = await svc.cancel(parent.id, agent_role="cell_pm")
assert out is not None
refreshed_completed = await svc.get(completed_child.id)
assert refreshed_completed is not None
assert refreshed_completed.status == TaskStatus.COMPLETED
refreshed_pending = await svc.get(pending_child.id)
assert refreshed_pending is not None
assert refreshed_pending.status == TaskStatus.CANCELLED
@pytest.mark.asyncio
async def test_cancel_returns_task_with_cancellation_note_appended(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.dev_notes = "earlier note"
out = await svc.cancel(task.id, cancellation_note="duplicate")
assert out is not None
assert "duplicate" in (out.dev_notes or "")
# ---------------------------------------------------------------------------
# _unblock_dependents
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unblock_dependents_clears_dep_id(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Completing a blocker unblocks its dependents."""
svc = task_setup["svc"]
blocker = await svc.create(_req(task_setup))
dependent = await svc.create(_req(task_setup))
dependent.status = TaskStatus.BLOCKED
dependent.dependency_ids = [blocker.id]
await db_session.flush()
await svc._unblock_dependents(blocker.id)
refreshed = await svc.get(dependent.id)
assert refreshed is not None
assert blocker.id not in refreshed.dependency_ids
# The cleared dependency is remembered so the unblock briefing can surface it.
assert blocker.id in refreshed.completed_dependency_ids
assert refreshed.status == TaskStatus.IN_PROGRESS
@pytest.mark.asyncio
async def test_unblock_dependents_keeps_blocked_when_other_deps_remain(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
blocker = await svc.create(_req(task_setup))
other_blocker = await svc.create(_req(task_setup))
dependent = await svc.create(_req(task_setup))
dependent.status = TaskStatus.BLOCKED
dependent.dependency_ids = [blocker.id, other_blocker.id]
await db_session.flush()
await svc._unblock_dependents(blocker.id)
refreshed = await svc.get(dependent.id)
assert refreshed is not None
# Still blocked because other_blocker is still in deps
assert refreshed.status == TaskStatus.BLOCKED
# but the one dependency that did complete is recorded.
assert blocker.id in refreshed.completed_dependency_ids
assert other_blocker.id not in refreshed.completed_dependency_ids
# ---------------------------------------------------------------------------
# claim — gate validations (team mismatch, role mismatch, self-review)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_rejects_when_agent_team_mismatch(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
# Create an agent on a different team
other = AgentTable(
id=uuid4(),
name="FE",
slug=f"fe-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.FRONTEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(other)
await db_session.flush()
task = await svc.create(_req(task_setup, team=Team.BACKEND))
task.branch_name = "feature/backend/x"
await db_session.flush()
out = await svc.claim(task.id, other.id)
assert out is None
@pytest.mark.asyncio
async def test_claim_rejects_self_review_for_qa(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
qa_agent = AgentTable(
id=uuid4(),
name="QA",
slug=f"be-qa-{uuid4().hex[:8]}",
role=AgentRole.QA,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="qa",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(qa_agent)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.branch_name = "feature/backend/x"
task.quick_context = f"original_developer:{qa_agent.id}"
await db_session.flush()
out = await svc.claim(task.id, qa_agent.id)
assert out is None
@pytest.mark.asyncio
async def test_claim_management_role_can_claim_any_team(
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()
task = await svc.create(_req(task_setup, team=Team.BACKEND))
task.branch_name = "feature/backend/x"
await db_session.flush()
claimed = await svc.claim(task.id, main_pm.id)
assert claimed is not None
assert claimed.assigned_to == main_pm.id
# ---------------------------------------------------------------------------
# pass_qa with notes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pass_qa_records_notes(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.pr_number = 1
task.pr_url = "u"
await db_session.flush()
passed = await svc.pass_qa(task.id, notes="LGTM detailed", agent_role="qa")
assert passed is not None
assert passed.qa_notes == "LGTM detailed"
@pytest.mark.asyncio
async def test_pass_qa_resets_docs_complete(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Passing QA forces docs_complete=False so the documenter must redo."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.pr_number = 1
task.pr_url = "u"
task.docs_complete = True
await db_session.flush()
passed = await svc.pass_qa(task.id, agent_role="qa")
assert passed is not None
assert passed.docs_complete is False
# ---------------------------------------------------------------------------
# claim_seeds_branch via auto-create — failure rollback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_claim_rolls_back_on_branch_failure(
task_setup: dict, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When _ensure_branch_for_task fails, claim fields revert."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
# No branch — claim will try to auto-create.
await db_session.flush()
async def _fail(*_args: object, **_kwargs: object) -> str:
raise ValueError("simulated branch failure")
monkeypatch.setattr(svc, "_ensure_branch_for_task", _fail)
with pytest.raises(ValueError, match="simulated branch failure"):
await svc.claim(task.id, task_setup["agent_id"])
refreshed = await svc.get(task.id)
assert refreshed is not None
# Status rolled back to PENDING
assert refreshed.status == TaskStatus.PENDING
assert refreshed.assigned_to is None
# ---------------------------------------------------------------------------
# submit_for_pm_review — full happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_for_pm_review_returns_none_when_not_in_progress(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
out = await svc.submit_for_pm_review(task.id, agent_role="cell_pm")
assert out is None
@pytest.mark.asyncio
async def test_submit_for_pm_review_returns_none_when_no_branch(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.branch_name = None
await db_session.flush()
out = await svc.submit_for_pm_review(task.id, agent_role="cell_pm")
assert out is None
@pytest.mark.asyncio
async def test_submit_for_pm_review_returns_none_when_no_pr(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.branch_name = "feature/backend/x"
task.pr_created = False
await db_session.flush()
out = await svc.submit_for_pm_review(task.id, agent_role="cell_pm")
assert out is None
@pytest.mark.asyncio
async def test_submit_for_pm_review_returns_none_when_active_descendants(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
parent.status = TaskStatus.IN_PROGRESS
parent.branch_name = "feature/backend/x"
parent.pr_created = True
parent.pr_number = 1
await db_session.flush()
# Create a non-terminal child
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
assert child is not None # ensure child exists
out = await svc.submit_for_pm_review(parent.id, agent_role="cell_pm")
assert out is None
@pytest.mark.asyncio
async def test_submit_for_pm_review_advances_with_notes(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.branch_name = "feature/backend/x"
task.pr_created = True
task.pr_number = 1
task.docs_complete = True
await db_session.flush()
out = await svc.submit_for_pm_review(
task.id, agent_role="cell_pm", notes="ready for review"
)
assert out is not None
assert out.status == TaskStatus.AWAITING_PM_REVIEW
# ---------------------------------------------------------------------------
# docs_complete: full path including descendants check
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_docs_complete_returns_none_when_active_descendants(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
parent.status = TaskStatus.AWAITING_DOCUMENTATION
parent.assigned_to = task_setup["agent_id"]
parent.pr_number = 1
parent.pr_url = "u"
await db_session.flush()
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
assert child is not None
out = await svc.docs_complete(parent.id, doc_notes="docs done")
assert out is None
@pytest.mark.asyncio
async def test_docs_complete_returns_none_when_invalid_status(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
out = await svc.docs_complete(task.id, doc_notes="docs")
assert out is None
@pytest.mark.asyncio
async def test_docs_complete_advances_when_pr_already_created(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_DOCUMENTATION
task.assigned_to = task_setup["agent_id"]
task.pr_number = 1
task.pr_url = "u"
task.pr_created = True
await db_session.flush()
out = await svc.docs_complete(task.id, doc_notes="documented all flows")
assert out is not None
assert out.docs_complete is True
# Both flags now true → advances to AWAITING_PM_REVIEW
assert out.status == TaskStatus.AWAITING_PM_REVIEW
# ---------------------------------------------------------------------------
# mark_pr_created edge cases
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mark_pr_created_when_docs_not_complete_keeps_status(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_DOCUMENTATION
task.assigned_to = task_setup["agent_id"]
task.docs_complete = False
await db_session.flush()
pr_num = 10
out = await svc.mark_pr_created(task.id, pr_number=pr_num, pr_url="u10")
assert out is not None
# PR set, but stays in awaiting_documentation
assert out.pr_number == pr_num
assert out.status == TaskStatus.AWAITING_DOCUMENTATION
# ---------------------------------------------------------------------------
# complete — happy path & edge cases
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_complete_returns_none_when_invalid_status(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup)) # PENDING
out = await svc.complete(task.id, agent_id=task_setup["agent_id"])
assert out is None
@pytest.mark.asyncio
async def test_complete_returns_none_when_active_descendants(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
parent.status = TaskStatus.AWAITING_PM_REVIEW
await db_session.flush()
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
await db_session.flush()
assert child is not None
out = await svc.complete(parent.id, agent_id=task_setup["agent_id"])
assert out is None
@pytest.mark.asyncio
async def test_complete_in_progress_for_own_task(
task_setup: dict, db_session: AsyncSession
) -> None:
"""PM completes their own in_progress task (not awaiting_pm_review)."""
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()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = pm_agent.id
await db_session.flush()
out = await svc.complete(task.id, agent_id=pm_agent.id)
assert out is not None
assert out.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_complete_cell_pm_does_not_escalate_to_main_pm(
task_setup: dict, db_session: AsyncSession
) -> None:
"""#178: cell_pm completing an awaiting_pm_review non-root task
transitions it to COMPLETED and does NOT reassign to main_pm.
Pre-#178 the cell_pm branch of ``_apply_complete_approval_chain``
reassigned every awaiting_pm_review task to main_pm and kept it in
awaiting_pm_review for a second-tier review — but the gateway's
``main_pm_complete`` rejects every non-root task
(``parent_task_id IS NOT NULL`` → invalid_state), so main_pm had
no verb to advance it. Result: the leaf was permanently wedged
(observed end-to-end this session). The fix removes the cell_pm
escalation branch; cell PM now completes non-root tasks directly,
and cell→main escalation, when intended, uses ``submit_up``.
"""
svc = task_setup["svc"]
cell_pm = AgentTable(
id=uuid4(),
name="CellPM",
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={},
)
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_all([cell_pm, main_pm])
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_PM_REVIEW
task.assigned_to = cell_pm.id
await db_session.flush()
out = await svc.complete(task.id, agent_id=cell_pm.id)
assert out is not None
assert out.status == TaskStatus.COMPLETED
assert out.assigned_to != main_pm.id
@pytest.mark.asyncio
async def test_complete_cell_pm_no_main_pm_completes(
task_setup: dict, db_session: AsyncSession
) -> None:
"""#178: cell_pm completing awaiting_pm_review transitions to
COMPLETED regardless of whether a Main PM exists. (Pre-#178 this
test guarded the "no Main PM → escalation returns None → falls
through to completion" fallback; post-#178 the cell_pm escalation
branch is gone entirely, so this path is the only path.)
"""
svc = task_setup["svc"]
cell_pm = AgentTable(
id=uuid4(),
name="CellPM",
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(cell_pm)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_PM_REVIEW
await db_session.flush()
out = await svc.complete(task.id, agent_id=cell_pm.id)
assert out is not None
# No Main PM in scope — escalation returned None, falls through to completion
assert out.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_complete_with_force_with_cancelled_succeeds(
task_setup: dict, db_session: AsyncSession
) -> None:
"""force_with_cancelled allows completion despite cancelled descendants.
Strip any leaked Main PMs so the Cell PM completion doesn't escalate
upward and short-circuit the cancelled-descendant code path.
"""
svc = task_setup["svc"]
await db_session.execute(
AgentTable.__table__.update()
.where(AgentTable.role == AgentRole.MAIN_PM)
.values(role=AgentRole.SYSTEM)
)
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))
parent.status = TaskStatus.AWAITING_PM_REVIEW
await db_session.flush()
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
child.status = TaskStatus.CANCELLED
await db_session.flush()
out = await svc.complete(
parent.id,
agent_id=pm_agent.id,
force_with_cancelled=True,
justification="not needed anymore",
)
assert out is not None
assert out.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_complete_without_force_with_cancelled_blocks(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Without force_with_cancelled, cancelled descendants block completion."""
svc = task_setup["svc"]
await db_session.execute(
AgentTable.__table__.update()
.where(AgentTable.role == AgentRole.MAIN_PM)
.values(role=AgentRole.SYSTEM)
)
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))
parent.status = TaskStatus.AWAITING_PM_REVIEW
await db_session.flush()
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
child.status = TaskStatus.CANCELLED
await db_session.flush()
out = await svc.complete(parent.id, agent_id=pm_agent.id)
assert out is None
# ---------------------------------------------------------------------------
# apply_escalation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_apply_escalation_reassigns_and_blocks(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
target = AgentTable(
id=uuid4(),
name="Target",
slug=f"target-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="t",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(target)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.assigned_to = task_setup["agent_id"]
task.status = TaskStatus.IN_PROGRESS
await db_session.flush()
await svc.apply_escalation(
task=task,
target_agent_id=target.id,
escalator_slug="dev-1",
target_slug="cell-pm",
reason="external blocker",
)
assert task.status == TaskStatus.BLOCKED
assert task.assigned_to == target.id
assert task.blocker_raised_by == task_setup["agent_id"]
assert "[ESCALATED]" in (task.dev_notes or "")
# ---------------------------------------------------------------------------
# escalate / escalate_up_to_role helpers
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_escalate_returns_none_for_missing_task(task_setup: dict) -> None:
svc = task_setup["svc"]
out = await svc.escalate(task_setup["agent_id"], uuid4(), reason="x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_returns_none_for_missing_agent(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
out = await svc.escalate(uuid4(), task.id, reason="x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_returns_none_when_target_slug_not_in_db(
task_setup: dict,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Escalation target slug exists in config but no AgentTable row matches."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
# Force a target slug that won't match any AgentTable row
monkeypatch.setattr(
"roboco.agents_config.get_escalation_target",
lambda _slug: "nonexistent-target",
)
out = await svc.escalate(task_setup["agent_id"], task.id, reason="x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_succeeds_when_target_resolves(
task_setup: dict,
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = task_setup["svc"]
target = AgentTable(
id=uuid4(),
name="Target",
slug=f"esc-target-{uuid4().hex[:8]}",
role=AgentRole.CELL_PM,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="t",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(target)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.assigned_to = task_setup["agent_id"]
task.status = TaskStatus.IN_PROGRESS
await db_session.flush()
monkeypatch.setattr(
"roboco.agents_config.get_escalation_target",
lambda _slug: target.slug,
)
out = await svc.escalate(task_setup["agent_id"], task.id, reason="bug")
assert out is not None
assert out.assigned_to == target.id
@pytest.mark.asyncio
async def test_escalate_up_to_role_returns_none_for_missing_task(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
out = await svc.escalate_up_to_role(task_setup["agent_id"], uuid4(), "main_pm", "x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_up_to_role_returns_none_for_missing_agent(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
out = await svc.escalate_up_to_role(uuid4(), task.id, "main_pm", "x")
assert out is None
@pytest.mark.asyncio
async def test_escalate_up_to_role_succeeds(
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="m",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(main_pm)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.assigned_to = task_setup["agent_id"]
task.status = TaskStatus.IN_PROGRESS
await db_session.flush()
out = await svc.escalate_up_to_role(
task_setup["agent_id"], task.id, "main_pm", "needs main pm"
)
assert out is not None
@pytest.mark.asyncio
async def test_escalate_up_to_role_returns_none_when_no_target_role(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Target role has no agents in DB.
Strip any leaked Main PMs so escalation hits the no-target branch.
"""
svc = task_setup["svc"]
await db_session.execute(
AgentTable.__table__.update()
.where(AgentTable.role == AgentRole.MAIN_PM)
.values(role=AgentRole.SYSTEM)
)
task = await svc.create(_req(task_setup))
await db_session.flush()
out = await svc.escalate_up_to_role(task_setup["agent_id"], task.id, "main_pm", "x")
# No main_pm in scope; the unknown-role branch would also short-circuit
assert out is None
# ---------------------------------------------------------------------------
# unblock_with_restore - full snapshot path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unblock_with_restore_with_invalid_pre_block_state(
task_setup: dict, db_session: AsyncSession
) -> None:
"""An invalid pre_block_state value falls through to legacy unblock."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.BLOCKED
task.pre_block_state = "garbage"
# Has a branch (was claimed before blocking) so legacy unblock resumes
# in_progress rather than returning a never-claimed task to pending.
task.branch_name = "feature/backend/abc12345"
await db_session.flush()
out = await svc.unblock_with_restore(
pm_agent_id=task_setup["agent_id"], task_id=task.id, restore=True
)
# Falls through to legacy unblock which transitions to in_progress
assert out is not None
assert out.status == TaskStatus.IN_PROGRESS
@pytest.mark.asyncio
async def test_unblock_with_restore_when_status_not_blocked(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.pre_block_state = TaskStatus.IN_PROGRESS.value
await db_session.flush()
out = await svc.unblock_with_restore(
pm_agent_id=task_setup["agent_id"], task_id=task.id, restore=True
)
# status not BLOCKED but pre_block_state is set → returns None
assert out is None
# ---------------------------------------------------------------------------
# qa_pass and qa_fail with actor mismatch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_qa_pass_logs_actor_mismatch(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
qa_a = AgentTable(
id=uuid4(),
name="QA-A",
slug=f"be-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"be-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
task.pr_number = 1
task.pr_url = "u"
await db_session.flush()
out = await svc.qa_pass(qa_b.id, task.id, "looks ok")
# Pass succeeds despite mismatch (warning logged)
assert out is not None
@pytest.mark.asyncio
async def test_qa_fail_appends_issues_and_calls_fail_qa(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
qa_agent = AgentTable(
id=uuid4(),
name="QA",
slug=f"be-qa-{uuid4().hex[:8]}",
role=AgentRole.QA,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="qa",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(qa_agent)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.claimed_by = qa_agent.id
await db_session.flush()
out = await svc.qa_fail(
qa_agent.id,
task.id,
notes="needs work",
issues=["typo", "missing test"],
)
assert out is not None
assert out.status == TaskStatus.NEEDS_REVISION
# Issues block was appended to dev_notes
assert "typo" in (out.dev_notes or "")
assert "missing test" in (out.dev_notes or "")
@pytest.mark.asyncio
async def test_qa_fail_returns_none_for_missing_task(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
out = await svc.qa_fail(uuid4(), uuid4(), notes="x", issues=[])
assert out is None
# ---------------------------------------------------------------------------
# pause_for_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pause_for_agent_returns_none_when_wrong_assignee(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
other = AgentTable(
id=uuid4(),
name="Other",
slug=f"other-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(other)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = other.id
await db_session.flush()
out = await svc.pause_for_agent(
agent_id=task_setup["agent_id"], task_id=task.id, agent_role="developer"
)
assert out is None
@pytest.mark.asyncio
async def test_pause_for_agent_calls_pause_when_owner(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
out = await svc.pause_for_agent(
agent_id=task_setup["agent_id"],
task_id=task.id,
agent_role="developer",
)
assert out is not None
assert out.status == TaskStatus.PAUSED
# ---------------------------------------------------------------------------
# list_in_progress_for_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_in_progress_for_agent(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
rows = await svc.list_in_progress_for_agent(task_setup["agent_id"])
assert task.id in {t.id for t in rows}
# ---------------------------------------------------------------------------
# create_subtask
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_subtask_requires_parent_task_id(task_setup: dict) -> None:
svc = task_setup["svc"]
with pytest.raises(ValueError, match="parent_task_id"):
await svc.create_subtask(_req(task_setup, parent_task_id=None))
@pytest.mark.asyncio
async def test_create_subtask_with_assignee_uses_pending(
task_setup: dict,
) -> None:
# `create_subtask` enforces TASK_AT_CREATE completeness (Task 18, 2026-05-10),
# so we pass a description that meets the 20-char minimum.
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
sub = await svc.create_subtask(
_req(
task_setup,
description="Subtask with explicit description for completeness rule.",
parent_task_id=parent.id,
assigned_to=task_setup["agent_id"],
)
)
assert sub.status == TaskStatus.PENDING
@pytest.mark.asyncio
async def test_create_subtask_without_assignee_uses_backlog(
task_setup: dict,
) -> None:
# See test above re: completeness rule on description length.
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
sub = await svc.create_subtask(
_req(
task_setup,
description="Subtask with explicit description for completeness rule.",
parent_task_id=parent.id,
assigned_to=None,
)
)
assert sub.status == TaskStatus.BACKLOG
# ---------------------------------------------------------------------------
# submit_pm_review (gateway alias)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_pm_review_with_notes(
task_setup: dict,
db_session: AsyncSession,
) -> None:
svc = task_setup["svc"]
cell_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(cell_pm)
await db_session.flush()
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.branch_name = "feature/backend/x"
task.pr_created = True
task.pr_number = 1
await db_session.flush()
out = await svc.submit_pm_review(cell_pm.id, task.id, "ready")
assert out is not None
# ---------------------------------------------------------------------------
# resolve_agent_id failure path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_agent_id_raises_for_unknown_slug(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
with pytest.raises(NotFoundError):
await svc.resolve_agent_id("nonexistent-slug")
# ---------------------------------------------------------------------------
# main_pm_agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_main_pm_agent_returns_seeded(
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="m",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(main_pm)
await db_session.flush()
out = await svc.main_pm_agent()
assert out is not None
assert out.role == AgentRole.MAIN_PM
# ---------------------------------------------------------------------------
# get_active_task_for_agent — returns the most recent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_active_task_for_agent_picks_in_progress(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = task_setup["agent_id"]
await db_session.flush()
out = await svc.get_active_task_for_agent(task_setup["agent_id"])
assert out is not None
assert out.id == task.id
# ---------------------------------------------------------------------------
# list_paused_for_agent — returns paused tasks
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_paused_for_agent_returns_paused_only(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
paused = await svc.create(_req(task_setup))
paused.status = TaskStatus.PAUSED
paused.assigned_to = task_setup["agent_id"]
await db_session.flush()
rows = await svc.list_paused_for_agent(task_setup["agent_id"])
assert paused.id in {t.id for t in rows}
# ---------------------------------------------------------------------------
# list_long_running_blocked
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_long_running_blocked_returns_list(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
rows = await svc.list_long_running_blocked(threshold_minutes=1)
assert isinstance(rows, list)
# ---------------------------------------------------------------------------
# list_strategic_for_board
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_strategic_for_board_excludes_subtasks(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Only root tasks (no parent) with NON_TECHNICAL nature in PM review."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup, nature=TaskNature.NON_TECHNICAL))
parent.status = TaskStatus.AWAITING_PM_REVIEW
sub = await svc.create(
_req(
task_setup,
nature=TaskNature.NON_TECHNICAL,
parent_task_id=parent.id,
)
)
sub.status = TaskStatus.AWAITING_PM_REVIEW
await db_session.flush()
rows = await svc.list_strategic_for_board()
ids = {t.id for t in rows}
assert parent.id in ids
assert sub.id not in ids
# ---------------------------------------------------------------------------
# emit_task_event coverage — best-effort
# ---------------------------------------------------------------------------
class _BadBus:
def is_connected(self) -> bool:
raise RuntimeError("bus dead")
def _bad_bus_factory() -> _BadBus:
return _BadBus()
@pytest.mark.asyncio
async def test_emit_task_event_swallows_exception(
task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If event bus raises, _emit_task_event must log + return without raising."""
svc = task_setup["svc"]
monkeypatch.setattr("roboco.services.task.get_event_bus", _bad_bus_factory)
# No raise:
await svc._emit_task_event(EventType.TASK_AWAITING_CEO_APPROVAL, uuid4())
@pytest.mark.asyncio
async def test_emit_task_event_publishes_when_connected(
task_setup: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
svc = task_setup["svc"]
publish_mock = AsyncMock()
class _Bus:
def is_connected(self) -> bool:
return True
async def publish(self, event) -> None:
await publish_mock(event)
def _bus_factory() -> _Bus:
return _Bus()
monkeypatch.setattr("roboco.services.task.get_event_bus", _bus_factory)
await svc._emit_task_event(
EventType.TASK_AWAITING_CEO_APPROVAL, uuid4(), {"key": "value"}
)
publish_mock.assert_awaited_once()
# ---------------------------------------------------------------------------
# resolve_pm_for_review chain walks up parents
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_pm_for_review_finds_assignee_up_chain(
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()
grandparent = await svc.create(_req(task_setup, assigned_to=pm_agent.id))
parent = await svc.create(_req(task_setup, parent_task_id=grandparent.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
@pytest.mark.asyncio
async def test_resolve_pm_for_review_returns_none_when_root(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
root = await svc.create(_req(task_setup))
pm_id = await svc._resolve_pm_for_review(root)
assert pm_id is None
@pytest.mark.asyncio
async def test_resolve_pm_for_review_returns_none_when_parent_chain_unassigned(
task_setup: dict,
) -> None:
"""Edge case: when no ancestor in the chain has an assignee."""
svc = task_setup["svc"]
grand = await svc.create(_req(task_setup)) # Unassigned
parent = await svc.create(_req(task_setup, parent_task_id=grand.id)) # Unassigned
child = await svc.create(_req(task_setup, parent_task_id=parent.id))
pm_id = await svc._resolve_pm_for_review(child)
assert pm_id is None