[fix] fail_qa routes needs_revision back to the dev, never the pool

A dev task in needs_revision must go back to the developer, never the
pool. The pool path let a cell PM re-claim the revision (PMs can claim
needs_revision) — the live 2026-06-27 'needs revision on a dev task sent to
the cell PM' bug.

fail_qa's original_developer marker is the fast path, but it is
unreliable in practice (live observation: never persisted), so the
unassign else-branch was the load-bearing path and it dropped the task
into the pool. Add a work-session fallback (_resolve_revision_dev) that
resolves the developer who actually worked the task — the most recent
work session whose agent is a developer, the QA's own session excluded
— and reassigns to that dev instead of unassigning. Only unassign when
no developer ever touched the task. Self-heals the marker so a
subsequent re-fail takes the fast path and the QA-review index
attributes the work correctly.
This commit is contained in:
Renn F
2026-06-28 03:55:24 +02:00
parent 6f8d0a4e0b
commit a2bc2f1b97
2 changed files with 213 additions and 6 deletions
+69 -6
View File
@@ -4090,13 +4090,41 @@ class TaskService(BaseService):
original_developer=original_dev,
)
else:
# If no original developer found, unassign so it can be claimed
task.assigned_to = None
task.claimed_by = None
self.log.warning(
"No original developer found, task unassigned",
task_id=str(task_id),
# A dev task in needs_revision must go back to THE DEV, never to
# the pool. ``submit_for_qa`` sets the ``original_developer`` marker
# on the normal path, so a missing marker means the task did not
# pass through it. Unassigning here used to drop the task into the
# pool, where a cell PM (PMs can re-claim needs_revision) would
# grab it — exactly "needs revision on a dev task sent to the cell
# PM" (live 2026-06-27). Fall back to the developer who actually
# worked it: the most recent work session whose agent is a
# developer (the QA's own session excluded). Only unassign when no
# developer ever touched the task.
fallback_dev = await self._resolve_revision_dev(
task, exclude=to_python_uuid(qa_agent_id)
)
if fallback_dev is not None:
task.assigned_to = cast("Any", fallback_dev)
task.claimed_by = cast("Any", fallback_dev)
# Self-heal the marker so a subsequent re-fail takes the fast
# path and the QA-review index (below) attributes the work to
# the right developer. The marker is unreliable in practice
# (live 2026-06-27: never persisted), so the work session is
# the load-bearing resolver; stamping it here makes the two
# paths converge instead of the marker staying absent forever.
markers.set_original_developer(task, str(fallback_dev))
self.log.info(
"Task reassigned to revision developer (marker missing)",
task_id=str(task_id),
revision_developer=str(fallback_dev),
)
else:
task.assigned_to = None
task.claimed_by = None
self.log.warning(
"No developer found for revision; task unassigned",
task_id=str(task_id),
)
await self.session.flush()
@@ -4125,6 +4153,41 @@ class TaskService(BaseService):
self.log.info("Task failed QA", task_id=str(task_id))
return task
async def _resolve_revision_dev(
self, task: TaskTable, *, exclude: UUID | None
) -> UUID | None:
"""The developer who should receive a needs_revision dev task when the
``original_developer`` marker is missing.
A dev task in needs_revision must return to the dev, never the pool: an
unassigned needs_revision task is claimable by a cell PM, which is how a
dev task's revision landed on the cell PM live (2026-06-27). Resolves the
developer who actually worked it the most recent work session on the
task whose agent is a developer (the QA's own session, ``exclude``, is
skipped so the fallback can't hand the task back to QA). Returns None
only when no developer ever touched the task (then unassign is the last
resort).
"""
conditions = [WorkSessionTable.task_id == task.id]
if exclude is not None:
conditions.append(WorkSessionTable.agent_id != exclude)
result = await self.session.execute(
select(WorkSessionTable)
.where(and_(*conditions))
.order_by(WorkSessionTable.started_at.desc())
)
for ws in result.scalars().all():
agent_id = to_python_uuid(ws.agent_id)
if agent_id is None:
continue
agent = await self.agent_for(agent_id)
if agent is None:
continue
role = agent.role.value if hasattr(agent.role, "value") else str(agent.role)
if role == "developer":
return agent_id
return None
async def docs_complete(
self,
task_id: UUID,
@@ -18,6 +18,7 @@ from roboco.db.tables import (
JournalEntryTable,
ProductTable,
ProjectTable,
WorkSessionTable,
)
from roboco.events import EventType
from roboco.foundation.policy.content import markers
@@ -31,6 +32,7 @@ from roboco.models.base import (
TaskType,
)
from roboco.models.task import TaskCreateRequest
from roboco.models.work_session import WorkSessionStatus
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.base import NotFoundError
from roboco.services.task import SoftBlockInfo, TaskService
@@ -602,6 +604,148 @@ async def test_fail_qa_with_no_original_dev_unassigns(
assert failed.assigned_to is None
@pytest.mark.asyncio
async def test_fail_qa_routes_to_dev_via_work_session_when_marker_missing(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A dev task in needs_revision with a MISSING original_developer marker
routes back to the developer who worked it (resolved from the work
session), NOT to the pool.
Unassigning sends the task to the pool where a cell PM (PMs can re-claim
needs_revision) grabs it the live 2026-06-27 "needs revision on a dev
task sent to the cell PM" bug. The marker is the fast path; the work
session is the load-bearing fallback (the marker is unreliable in
practice). The fallback also self-heals the marker so a subsequent
re-fail takes the fast path.
"""
svc = task_setup["svc"]
dev_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.branch_name = "feature/backend/abc"
await db_session.flush()
# The dev worked the task (a work session exists) but the marker was never
# set — e.g. the task was rerouted before submit_for_qa ran, or the marker
# failed to persist (live observation). This is the "OG DEV NEVER
# PERSISTED" scenario.
ws = WorkSessionTable(
id=uuid4(),
project_id=task_setup["project_id"],
task_id=task.id,
agent_id=dev_id,
branch_name="feature/backend/abc",
base_branch="main",
target_branch="main",
status=WorkSessionStatus.ACTIVE,
)
db_session.add(ws)
# A separate QA agent claims and fails the task.
qa = 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)
await db_session.flush()
task.status = TaskStatus.AWAITING_QA
task.assigned_to = qa.id
task.claimed_by = qa.id
# No orchestration_markers — 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.status == TaskStatus.NEEDS_REVISION
# Routed back to the dev — NOT unassigned (pool, where a cell PM would
# claim it) and NOT left on the QA.
assert failed.assigned_to == dev_id
assert failed.claimed_by == dev_id
# Self-healed: the marker is now stamped so the next fail_qa uses the
# fast path and the QA-review index attributes the work correctly.
assert markers.get_original_developer(failed) == str(dev_id)
@pytest.mark.asyncio
async def test_fail_qa_work_session_fallback_excludes_qa_session(
task_setup: dict, db_session: AsyncSession
) -> None:
"""The work-session fallback must not hand the task back to the QA.
If the only developer work session were the QA's (it isn't QA sessions
are skipped at creation but defensively the exclude filter must keep a
QA-attributed session from being misread as the revision dev), the
fallback would loop the task back to the reviewer. The exclude filter
(qa_agent_id) guarantees only a real developer is resolved.
"""
svc = task_setup["svc"]
dev_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.branch_name = "feature/backend/abc"
await db_session.flush()
qa = 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)
await db_session.flush()
# A QA-attributed work session (older) and a dev-attributed one (newer).
qa_ws = WorkSessionTable(
id=uuid4(),
project_id=task_setup["project_id"],
task_id=task.id,
agent_id=qa.id,
branch_name="feature/backend/abc",
base_branch="main",
target_branch="main",
status=WorkSessionStatus.ACTIVE,
)
dev_ws = WorkSessionTable(
id=uuid4(),
project_id=task_setup["project_id"],
task_id=task.id,
agent_id=dev_id,
branch_name="feature/backend/abc",
base_branch="main",
target_branch="main",
status=WorkSessionStatus.ACTIVE,
)
db_session.add_all([qa_ws, dev_ws])
await db_session.flush()
task.status = TaskStatus.AWAITING_QA
task.assigned_to = qa.id
task.claimed_by = qa.id
await db_session.flush()
failed = await svc.fail_qa(task.id, notes="needs more")
assert failed is not None
# Resolved to the dev, NOT the QA — the exclude filter did its job.
assert failed.assigned_to == dev_id
assert failed.assigned_to != qa.id
# ---------------------------------------------------------------------------
# ceo_approve / ceo_reject — happy paths
# ---------------------------------------------------------------------------