Files
roboco/tests/unit/runtime/test_readiness_dependency_gate.py
3205443119 Fix: dependency spawn gate and cell ownership (#73)
* Cleanup + Missing greenlet error

* fix(messaging): persist a group's active-session pointer so posts reuse it

create_session and create_session_with_access_check set group.active_session_id
from session.id BEFORE the flush that materializes it — the id is a flush-time
uuid4 default, so the pointer was written as NULL and every post opened a fresh
session, fragmenting one conversation across many. Flush first, then link, the
same ordering the seed path already uses.

Two tests fabricated "two distinct sessions" by calling create_session twice on
one group, which only differed because of this bug; switch them to two groups so
they keep testing their real intent. Add a regression guard that the pointer is
actually persisted and a second create reuses the live session.

* fix(orchestrator): gate spawns on dependencies and keep cell tasks in their cell

The cross-task dependency check ran only on the dev dispatch path, so cell-PM,
Main-PM and board agents were spawned onto dependency-blocked tasks and flailed
unblock / escalate / notify against an unfinished upstream — climbing ownership
of cell work up to the board, which cannot drive it, and deadlocking the task.

- Move the dependency gate into the shared spawn readiness check so it covers
  every role, and auto-block the task so it leaves the pending pool until the
  upstream reaches a terminal state (then the existing auto-unblock revives it).
- Cell-ownership invariant: a backend/frontend/ux_ui task may only be worked or
  owned by its own cell. The readiness gate refuses a board or Main-PM spawn
  onto a cell task; reassign refuses and clears such an owner; and on
  dependency-clear a mis-owned cell task is re-homed to its cell's pending pool
  instead of reviving under an owner that cannot progress it.
- A dependency block is never a CEO signal: notify(target=ceo) is refused while
  the task is waiting on an unfinished upstream, with a remediate to idle and
  wait — the block clears on its own.

* Uploading images + Fixing pyproject.toml

* ++

* revert(orchestrator): drop the cell-ownership block pending a tooling audit

The cell-ownership invariant added earlier — a board / Main-PM role may never be
spawned onto or reassigned to a cell task, plus re-homing a mis-owned cell task
on dependency-clear — was too absolute. It forbids a higher role from stepping
in when something genuinely deeper is going on, and contradicts the existing
rule that main_pm may hold a task at awaiting_pm_review. The dependency spawn
gate already prevents the cascade that handed the board cell tasks; the deadlock
it guarded against will be addressed with a return-path approach after auditing
what tools the cell PMs actually need. Keeps the dependency gate and the CEO
dependency-block notify guard.

* docs(prompts): a dependency wait is wait-and-idle, not escalate

The cell-PM and Main-PM prompts told agents to escalate_up / retry unblock on a
blocked task without distinguishing a dependency wait (which auto-clears the
moment the upstream completes) from a real wedge — the source of the
escalate/unblock flail and the CEO-notification spam. Split the blocked-state
guidance: a cross-cell dependency wait = note + i_am_idle (do not escalate,
unblock, or notify the CEO); escalate only a genuinely broken upstream. Fix two
stale references to i_am_blocked, a developer-only verb the PMs do not have,
to escalate_up.

Correct the CLAUDE.md verb-surface table, which understated every role: it
listed 4 cell_pm verbs while the flow manifest derives the full set (11,
including unclaim and i_am_idle) from lifecycle.spec.intents_for_role.

* feat(gateway): cell_pm reassign verb — intra-cell developer hand-off

A cell PM can now hand a claimed/in_progress task to another developer in its
own cell without unclaim (which drops the work back to the pool and loses the
assignee). The branch is keyed to the task, so the work-in-progress is
preserved; the new dev is respawned to continue. Intra-cell only: the task must
be in the caller's cell and new_assignee must be a developer of that same cell.

Wired through every layer: the reassign IntentSpec (composes=(), cell_pm-only),
the choreographer verb + intra-cell guard, a reaper-safe
TaskService.reassign_active_claim (reseeds the claim heartbeat so the new dev
is not immediately reaped), the ReassignRequest schema, the cell_pm flow route,
and the MCP flow-server tool. Tracing-waived like unclaim (mechanical hand-off).
Regenerated lifecycle/verb artifacts; prompt + CLAUDE.md updated.

---------

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

229 lines
7.4 KiB
Python

"""The spawn readiness gate refuses ANY role onto a dependency-blocked task.
Previously the cross-task dependency check lived only on the dev dispatch
path, so cell-PM, Main-PM and board agents were spawned onto tasks whose
upstream (e.g. the UX/UI design a frontend task waits on) was still open.
Those agents then flailed unblock / escalate / notify against an unfinished
dependency. The gate now lives in ``_readiness_gate`` — the single pre-flight
every ``spawn_agent`` call funnels through — so it covers every role.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
import pytest
from roboco.runtime import orchestrator as orchestrator_module
from roboco.runtime.orchestrator import AgentOrchestrator
_TASK_ID = "11111111-1111-1111-1111-111111111111"
_DEP_ID = "22222222-2222-2222-2222-222222222222"
class _FakeResp:
def __init__(self, status_code: int = 200, payload: dict[str, Any] | None = None):
self.status_code = status_code
self._payload = payload or {}
@property
def is_success(self) -> bool:
return self.status_code == HTTPStatus.OK
def json(self) -> dict[str, Any]:
return self._payload
class _FakeClient:
"""Async-context HTTP stub routing by URL substring."""
def __init__(self, routes: dict[str, _FakeResp]):
self._routes = routes
self.patches: list[tuple[str, dict[str, Any] | None]] = []
async def __aenter__(self) -> _FakeClient:
return self
async def __aexit__(self, *_: object) -> bool:
return False
async def get(self, url: str) -> _FakeResp:
for key, resp in self._routes.items():
if key in url:
return resp
return _FakeResp(404, {})
async def patch(self, url: str, json: dict[str, Any] | None = None) -> _FakeResp:
# The dependency gate auto-blocks via PATCH; record + accept it.
self.patches.append((url, json))
return _FakeResp(200, {})
def _orch_with_routes(
monkeypatch: pytest.MonkeyPatch, routes: dict[str, _FakeResp]
) -> tuple[AgentOrchestrator, _FakeClient]:
orch = object.__new__(AgentOrchestrator)
client = _FakeClient(routes)
# `_api_url` is a property (reads settings); routes match by `/tasks/<id>`
# substring so the resolved base URL is irrelevant.
monkeypatch.setattr(
orchestrator_module.httpx,
"AsyncClient",
lambda *_a, **_k: client,
)
return orch, client
@pytest.mark.asyncio
async def test_cell_pm_refused_on_nonterminal_dependency(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A cell PM must NOT be spawned while a cross-cell dependency is open."""
routes = {
f"/tasks/{_TASK_ID}": _FakeResp(
200,
{
"id": _TASK_ID,
"status": "pending",
"dependency_ids": [_DEP_ID],
"acceptance_criteria": ["x"],
"project_id": "r1",
"project_slug": "roboco",
},
),
f"/tasks/{_DEP_ID}": _FakeResp(200, {"id": _DEP_ID, "status": "in_progress"}),
}
orch, _ = _orch_with_routes(monkeypatch, routes)
reason = await orch._readiness_gate("be-pm", _TASK_ID)
assert reason is not None
assert _DEP_ID in reason
@pytest.mark.asyncio
async def test_board_refused_on_nonterminal_dependency(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A board role (product-owner) is gated the same as everyone else."""
routes = {
f"/tasks/{_TASK_ID}": _FakeResp(
200,
{
"id": _TASK_ID,
"status": "pending",
"dependency_ids": [_DEP_ID],
"acceptance_criteria": ["x"],
"product_id": "p1",
"project_id": None,
},
),
f"/tasks/{_DEP_ID}": _FakeResp(200, {"id": _DEP_ID, "status": "paused"}),
}
orch, _ = _orch_with_routes(monkeypatch, routes)
reason = await orch._readiness_gate("product-owner", _TASK_ID)
assert reason is not None
assert _DEP_ID in reason
@pytest.mark.asyncio
async def test_unreadable_dependency_fails_closed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A dependency we cannot read is treated as unmet — never spawn ahead."""
routes = {
f"/tasks/{_TASK_ID}": _FakeResp(
200,
{
"id": _TASK_ID,
"status": "pending",
"dependency_ids": [_DEP_ID],
"acceptance_criteria": ["x"],
"product_id": "p1",
"project_id": None,
},
),
# no route for the dependency → 404 → unreadable
}
orch, _ = _orch_with_routes(monkeypatch, routes)
reason = await orch._readiness_gate("be-pm", _TASK_ID)
assert reason is not None
@pytest.mark.asyncio
async def test_allowed_when_dependency_terminal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Once the dependency is completed, the dependency gate no longer refuses."""
routes = {
f"/tasks/{_TASK_ID}": _FakeResp(
200,
{
"id": _TASK_ID,
"status": "pending",
"dependency_ids": [_DEP_ID],
"acceptance_criteria": ["x"],
# coordination task → project/branch/git-token gates skipped, so a
# passing dependency gate yields a clean None.
"product_id": "p1",
"project_id": None,
},
),
f"/tasks/{_DEP_ID}": _FakeResp(200, {"id": _DEP_ID, "status": "completed"}),
}
orch, _ = _orch_with_routes(monkeypatch, routes)
reason = await orch._readiness_gate("product-owner", _TASK_ID)
assert reason is None
@pytest.mark.asyncio
async def test_no_dependencies_passes(monkeypatch: pytest.MonkeyPatch) -> None:
"""A task with no dependencies is never refused by this gate."""
routes = {
f"/tasks/{_TASK_ID}": _FakeResp(
200,
{
"id": _TASK_ID,
"status": "pending",
"dependency_ids": [],
"acceptance_criteria": ["x"],
"product_id": "p1",
"project_id": None,
},
),
}
orch, _ = _orch_with_routes(monkeypatch, routes)
reason = await orch._readiness_gate("main-pm", _TASK_ID)
assert reason is None
@pytest.mark.asyncio
async def test_dependency_block_auto_blocks_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The refusal auto-blocks the task so it leaves the pending pool.
A transient (no-block) refusal would re-raise every tick and starve sibling
tasks in the same dispatcher loop; auto-blocking removes it from the pool
until the upstream completes.
"""
routes = {
f"/tasks/{_TASK_ID}": _FakeResp(
200,
{
"id": _TASK_ID,
"status": "pending",
"dependency_ids": [_DEP_ID],
"acceptance_criteria": ["x"],
"product_id": "p1",
"project_id": None,
},
),
f"/tasks/{_DEP_ID}": _FakeResp(200, {"id": _DEP_ID, "status": "in_progress"}),
}
orch, client = _orch_with_routes(monkeypatch, routes)
reason = await orch._readiness_gate("be-pm", _TASK_ID)
assert reason is not None
assert any(
_TASK_ID in url and (body or {}).get("status") == "blocked"
for url, body in client.patches
)