mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
The PR-gate turn cut (#295) already auto-submitted assembled tasks, but its refusals fell back to a PM spawn silently -- in production the PM turns the cut was meant to remove kept happening with no visible cause (live case: an AC-coverage refusal). The flag is gone (the fallback is the safety net), the umbrella/branchless exclusion uses the canonical batch predicates, and every refusal reason now rides into the spawned PM's prompt so the fallback starts informed.
221 lines
7.9 KiB
Python
221 lines
7.9 KiB
Python
"""The PR-gate turn cut: closure auto-submits assembled parents to the gate.
|
|
|
|
When every child of an assembled parent is terminal, the orchestrator used
|
|
to spawn the PM just to call submit_up/submit_root — a whole agent turn
|
|
whose substance (freshness rebase, integrity check, PR open) is
|
|
deterministic gate code. ``_try_auto_submit`` runs the REAL submit verb
|
|
through the internal API as the owning PM, unconditionally — this IS the
|
|
flow, not an opt-in switch. Only a gate rejection falls back to the classic
|
|
PM closure spawn. The PM's remaining turn is the one that needs judgment:
|
|
the final merge (or the revision) — and when it does fall back, the
|
|
rejection reason rides the closure prompt so the PM isn't rediscovering it
|
|
blind.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from roboco.runtime.orchestrator import AGENT_UUIDS, AgentOrchestrator
|
|
|
|
# The commit/notes validator's minimum substantive length.
|
|
_MIN_NOTES = 20
|
|
|
|
_CELL_TASK: dict[str, Any] = {
|
|
"id": "11111111-1111-1111-1111-111111111111",
|
|
"team": "backend",
|
|
"branch_name": "feature/backend/AAAA1111",
|
|
"project_id": "22222222-2222-2222-2222-222222222222",
|
|
"assigned_to": "33333333-3333-3333-3333-333333333333",
|
|
"status": "in_progress",
|
|
}
|
|
|
|
|
|
def _orch() -> AgentOrchestrator:
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
orch._tick_handled_tasks = set()
|
|
orch._bg_tasks = set()
|
|
return orch
|
|
|
|
|
|
def _client(envelope: dict[str, Any]) -> MagicMock:
|
|
response = MagicMock()
|
|
response.json.return_value = envelope
|
|
client = MagicMock()
|
|
client.post = AsyncMock(return_value=response)
|
|
return client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cell_parent_auto_submits_as_owning_pm() -> None:
|
|
orch = _orch()
|
|
client = _client({"status": "awaiting_pr_review", "error": None})
|
|
|
|
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
|
|
assert ok is True
|
|
assert reason is None
|
|
|
|
(url,), kwargs = client.post.call_args
|
|
assert url == f"{orch._api_url}/v1/flow/cell_pm/submit_up"
|
|
assert kwargs["headers"]["X-Agent-ID"] == _CELL_TASK["assigned_to"]
|
|
assert kwargs["headers"]["X-Agent-Role"] == "cell_pm"
|
|
assert kwargs["json"]["task_id"] == _CELL_TASK["id"]
|
|
assert len(kwargs["json"]["notes"]) >= _MIN_NOTES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_main_pm_root_auto_submits_submit_root() -> None:
|
|
orch = _orch()
|
|
client = _client({"status": "awaiting_pr_review", "error": None})
|
|
task = {**_CELL_TASK, "team": "main_pm"}
|
|
|
|
ok, reason = await orch._try_auto_submit(client, task, "main-pm")
|
|
assert ok is True
|
|
assert reason is None
|
|
(url,), kwargs = client.post.call_args
|
|
assert url == f"{orch._api_url}/v1/flow/main_pm/submit_root"
|
|
assert kwargs["headers"]["X-Agent-Role"] == "main_pm"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branchless_parent_never_auto_submits() -> None:
|
|
"""A branchless coordination parent (MegaTask umbrella) assembles no PR."""
|
|
orch = _orch()
|
|
client = _client({"error": None})
|
|
task = {**_CELL_TASK, "branch_name": None}
|
|
|
|
assert await orch._try_auto_submit(client, task, "be-pm") == (False, None)
|
|
client.post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_umbrella_shape_never_auto_submits_even_with_a_stray_branch() -> None:
|
|
"""Belt-and-suspenders: _auto_submit_target consults the canonical
|
|
is_branchless_coordination predicate (via _is_coordination_task), not
|
|
just branch_name/project_id truthiness — so a MegaTask umbrella shape
|
|
(batch_id set, no parent, no project/product) is excluded structurally,
|
|
matching every other git-exemption site in the codebase."""
|
|
orch = _orch()
|
|
client = _client({"error": None})
|
|
task = {
|
|
**_CELL_TASK,
|
|
"project_id": None,
|
|
"batch_id": "44444444-4444-4444-4444-444444444444",
|
|
"parent_task_id": None,
|
|
}
|
|
|
|
assert await orch._try_auto_submit(client, task, "be-pm") == (False, None)
|
|
client.post.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gate_rejection_falls_back_to_pm_spawn() -> None:
|
|
"""A rejection envelope (e.g. integrity/freshness refusal) means the PM
|
|
turn is genuinely needed — auto-submit yields to the closure spawn, and
|
|
the reason is threaded back for the PM's prompt. This fallback is the
|
|
sole safety net — there is no kill-switch on top of it."""
|
|
orch = _orch()
|
|
client = _client(
|
|
{"error": "invalid_state", "message": "assembled branch behind base"}
|
|
)
|
|
|
|
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
|
|
assert ok is False
|
|
assert reason == "invalid_state: assembled branch behind base"
|
|
client.post.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ac_coverage_gate_rejection_reason_includes_remediate() -> None:
|
|
"""A parent-AC-coverage refusal (tracing_gap) folds remediate into the
|
|
reason so the PM's closure prompt names the uncovered criteria."""
|
|
orch = _orch()
|
|
remediate = (
|
|
"1 parent acceptance criteria are not covered by a completed subtask "
|
|
"before bubbling up: AC-2. Delegate a subtask to cover it."
|
|
)
|
|
client = _client(
|
|
{
|
|
"error": "tracing_gap",
|
|
"message": f"missing required tracing: [...]. {remediate}",
|
|
"remediate": remediate,
|
|
}
|
|
)
|
|
|
|
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
|
|
assert ok is False
|
|
assert reason is not None
|
|
assert "AC-2" in reason
|
|
# remediate is already folded into message by Envelope._missing_message;
|
|
# it must not be duplicated in the formatted reason.
|
|
assert reason.count("AC-2") == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subtasks_not_terminal_race_falls_back_cleanly() -> None:
|
|
"""A subtask flips non-terminal between the closure dispatcher's check
|
|
and the submit call (the race) — the gate's own re-check refuses and
|
|
auto-submit falls back cleanly, same as any other gate rejection."""
|
|
orch = _orch()
|
|
client = _client(
|
|
{
|
|
"error": "tracing_gap",
|
|
"message": (
|
|
"missing required tracing: ['subtasks not all terminal']. "
|
|
"all subtasks must be in completed/cancelled before bubbling "
|
|
"up. Non-terminal subtasks: ['55555555-5555-5555-5555-"
|
|
"555555555555']"
|
|
),
|
|
}
|
|
)
|
|
|
|
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
|
|
assert ok is False
|
|
assert reason is not None
|
|
assert "subtasks not all terminal" in reason
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_assignment_falls_back_to_static_identity() -> None:
|
|
orch = _orch()
|
|
client = _client({"status": "awaiting_pr_review", "error": None})
|
|
task = {**_CELL_TASK, "assigned_to": None}
|
|
|
|
ok, reason = await orch._try_auto_submit(client, task, "be-pm")
|
|
assert ok is True
|
|
assert reason is None
|
|
(_, kwargs) = client.post.call_args
|
|
assert kwargs["headers"]["X-Agent-ID"] == AGENT_UUIDS["be-pm"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_error_falls_back() -> None:
|
|
orch = _orch()
|
|
client = MagicMock()
|
|
client.post = AsyncMock(side_effect=RuntimeError("api down"))
|
|
|
|
ok, reason = await orch._try_auto_submit(client, _CELL_TASK, "be-pm")
|
|
assert ok is False
|
|
assert reason == "auto-submit transport error: api down"
|
|
|
|
|
|
def test_closure_prompt_threads_auto_submit_reason() -> None:
|
|
"""The fallback closure prompt surfaces the exact gate refusal so the
|
|
respawned PM doesn't re-run evidence-gathering to rediscover it blind."""
|
|
orch = _orch()
|
|
prompt = orch._build_pm_closure_prompt(
|
|
_CELL_TASK,
|
|
[],
|
|
auto_submit_reason="tracing_gap: parent AC-2 not covered",
|
|
)
|
|
assert "already attempted to auto-submit" in prompt
|
|
assert "tracing_gap: parent AC-2 not covered" in prompt
|
|
|
|
|
|
def test_closure_prompt_omits_note_when_no_reason() -> None:
|
|
orch = _orch()
|
|
prompt = orch._build_pm_closure_prompt(_CELL_TASK, [])
|
|
assert "already attempted to auto-submit" not in prompt
|