fix(gateway): cross-team planning fanout + complete tracing-gap hints

Task #157 — spine-cap allows planning fanout across cells:
    main-pm's pattern is to delegate planning to be-pm / fe-pm / ux-pm
    in parallel — each on a different team. The previous spine-cap
    rejected all planning siblings under one parent as
    over-decomposition. New helper _is_cross_team_planning skips the
    cap for planning when both teams are non-empty and distinct. Code
    / documentation stay capped regardless (single repo on one branch
    shouldn't have two simultaneous code subtasks).

Task #159 — tracing-gap remediate hints every requirement:
    journal:during_work>=1, journal:struggle, commits>=1, pr_open,
    and self_verified had no entries in _hint_for_missing_key, so when
    they were missing the agent saw the token in `missing[]` but the
    `remediate` text had no instruction for how to satisfy them.
    Smoke-10's be-dev-1 burned multiple turns retrying i_am_done not
    knowing scope='reflect' doesn't count toward during_work. Now
    every token has a hint, the during_work hint warns that reflect
    doesn't satisfy it, and multi-hint remediate uses a numbered list
    so the model treats each requirement as a distinct step instead
    of a semicolon-blob.

Also coerce convert_plan._coerce_risk formatting (ruff-format follow-up
to 9cd73d0).
This commit is contained in:
Renn F
2026-05-15 08:21:08 +02:00
parent 9cd73d0902
commit 5da909d9d7
4 changed files with 404 additions and 7 deletions
@@ -0,0 +1,128 @@
"""Task #157: spine-cap allows cross-team planning fanout.
Pre-fix:
main_pm delegates a planning subtask to be-pm (backend cell). When
it then tries to delegate a second planning subtask to fe-pm
(frontend cell), the spine-cap rejects because there's already a
non-terminal task_type='planning' under the parent. Pre-gateway
allowed this parallel cross-cell pattern; the gateway over-applied
the over-decomposition cap.
Fix:
`_sibling_dup_envelope` skips the spine-cap when:
- new task_type == "planning"
- new team != sibling team (both non-empty)
Other combinations stay capped:
- same-team planning: still over-decomposition (real bug)
- code / documentation regardless of team: a single repo on one
branch shouldn't have two simultaneous code subtasks
"""
from __future__ import annotations
from unittest.mock import MagicMock
from roboco.services.gateway.choreographer._impl import Choreographer
def _sibling(*, task_type: str, team: str, status: str = "pending") -> MagicMock:
sib = MagicMock()
sib.id = "11111111-aaaa-bbbb-cccc-dddddddddddd"
sib.status = status
sib.task_type = task_type
sib.team = team
sib.assigned_to = "some-pm"
return sib
def test_cross_team_planning_fanout_is_allowed() -> None:
"""main-pm: planning→be-pm (backend) exists; planning→fe-pm (frontend) must pass."""
sib = _sibling(task_type="planning", team="backend")
env = Choreographer._sibling_dup_envelope(
sibling=sib,
new_type="planning",
new_team="frontend",
new_assignee="fe-pm",
)
assert env is None, (
"Cross-team planning fanout (backend↔frontend) must NOT be rejected. "
f"Got envelope: {env}"
)
def test_cross_team_planning_third_cell_also_allowed() -> None:
"""Third cell (ux_ui) is also a valid cross-team planning fanout target."""
sib = _sibling(task_type="planning", team="backend")
env = Choreographer._sibling_dup_envelope(
sibling=sib,
new_type="planning",
new_team="ux_ui",
new_assignee="ux-pm",
)
assert env is None, env
def test_same_team_planning_still_rejected() -> None:
"""Two planning subtasks on the SAME team is the real over-decomp pattern —
must still be blocked by the spine-cap."""
sib = _sibling(task_type="planning", team="backend")
env = Choreographer._sibling_dup_envelope(
sibling=sib,
new_type="planning",
new_team="backend",
new_assignee="be-pm",
)
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state", body
def test_cross_team_code_still_rejected() -> None:
"""Code subtasks stay capped regardless of team — only one code task per
parent at a time. (Cross-team code under one parent is meaningless;
each cell's code work lives under its own cell-PM planning task.)"""
sib = _sibling(task_type="code", team="backend")
env = Choreographer._sibling_dup_envelope(
sibling=sib,
new_type="code",
new_team="frontend",
new_assignee="fe-dev-1",
)
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state", body
def test_cross_team_documentation_still_rejected() -> None:
"""Documentation subtasks stay capped regardless of team — single doc
pass per parent."""
sib = _sibling(task_type="documentation", team="backend")
env = Choreographer._sibling_dup_envelope(
sibling=sib,
new_type="documentation",
new_team="frontend",
new_assignee="fe-doc",
)
assert env is not None
def test_planning_with_missing_team_still_rejected() -> None:
"""If either side has no team attribute (defensive), fall back to the
strict cap. Don't let an empty-team escape hatch sneak past."""
sib_no_team = _sibling(task_type="planning", team="")
env = Choreographer._sibling_dup_envelope(
sibling=sib_no_team,
new_type="planning",
new_team="backend",
new_assignee="be-pm",
)
assert env is not None, "Empty team on sibling must NOT bypass the cap"
sib = _sibling(task_type="planning", team="backend")
env2 = Choreographer._sibling_dup_envelope(
sibling=sib,
new_type="planning",
new_team="",
new_assignee="be-pm",
)
assert env2 is not None, "Empty team on new task must NOT bypass the cap"
@@ -0,0 +1,178 @@
"""Task #159: tracing-gap remediate covers every missing requirement.
Pre-fix:
`journal:during_work>=1` (and a handful of other tokens) had no entry
in `_hint_for_missing_key`'s `simple_hints` dict. When the
requirement was missing, the agent saw the token in `missing[]` but
the `remediate` string contained NO instruction for how to satisfy
it. The agent fixed the items with hints, retried, hit the same
rejection again on the unhinted token, and looped.
Fix:
Register hints for the previously-unhinted tokens
(`journal:during_work>=1`, `journal:struggle`, `commits>=1`,
`pr_open`, `self_verified`). Multi-hint remediate switches to a
numbered list so the model sees each requirement as a distinct
instruction instead of a semicolon-blob.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
_MIN_HINT_LEN = 10
def _build_choreographer() -> Choreographer:
deps = ChoreographerDeps(
task=AsyncMock(),
work_session=AsyncMock(),
git=AsyncMock(),
a2a=AsyncMock(),
journal=AsyncMock(),
audit=AsyncMock(),
evidence_repo=AsyncMock(),
messaging=AsyncMock(),
)
repo = deps.evidence_repo
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
return Choreographer(deps)
# ---------------------------------------------------------------------------
# Hint registration — every previously-unhinted token now has a hint
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"token",
[
"journal:during_work>=1",
"journal:struggle",
"commits>=1",
"pr_open",
"self_verified",
],
)
def test_hint_registered_for_previously_unhinted_token(token: str) -> None:
"""Every requirement token must have a non-empty hint so the agent
knows how to satisfy it."""
hint = Choreographer._hint_for_missing_key(token, uuid4())
assert hint is not None, (
f"requirement token {token!r} has no hint; agent will see the "
f"token in `missing[]` but no actionable instruction"
)
assert len(hint) > _MIN_HINT_LEN, (
f"hint for {token!r} is suspiciously short: {hint!r}"
)
def test_during_work_hint_warns_reflect_doesnt_count() -> None:
"""The during_work hint must explicitly say reflect doesn't satisfy
it — that's the exact confusion smoke-10's be-dev-1 hit."""
hint = Choreographer._hint_for_missing_key("journal:during_work>=1", uuid4())
assert hint is not None
lower = hint.lower()
assert "reflect" in lower and (
"does not count" in lower
or "doesn't count" in lower
or "do not count" in lower
or "not count" in lower
), f"during_work hint must warn that scope='reflect' doesn't satisfy this: {hint!r}"
# ---------------------------------------------------------------------------
# Multi-hint remediate — numbered list, not semicolon-joined
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_single_missing_remediate_is_plain_string() -> None:
"""One missing item: remediate is the hint itself, no numbering."""
c = _build_choreographer()
env = await c._build_tracing_gap(uuid4(), uuid4(), ["journal:reflect"])
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert body["missing"] == ["journal:reflect"]
assert "Multiple requirements missing" not in (body["remediate"] or "")
assert "1." not in (body["remediate"] or "")
@pytest.mark.asyncio
async def test_multi_missing_remediate_is_numbered_list() -> None:
"""Multiple missing items: remediate must be a numbered list so the
agent treats each as a distinct step."""
c = _build_choreographer()
env = await c._build_tracing_gap(
uuid4(),
uuid4(),
["journal:reflect", "journal:during_work>=1", "commits>=1"],
)
body = env.as_dict()
remediate = body["remediate"] or ""
assert "Multiple requirements missing" in remediate, remediate
assert "1." in remediate
assert "2." in remediate
assert "3." in remediate
# All three hints must be substring-present (proves each was emitted).
assert "reflect" in remediate.lower()
assert "during" in remediate.lower() or "decision" in remediate.lower()
assert "commit" in remediate.lower()
@pytest.mark.asyncio
async def test_unhinted_token_still_appears_in_remediate() -> None:
"""If a future requirement is added without a hint (defense), the
agent at least sees the literal token in the remediate — not silently
dropped."""
c = _build_choreographer()
env = await c._build_tracing_gap(
uuid4(),
uuid4(),
["unknown_future_requirement"],
)
body = env.as_dict()
remediate = body["remediate"] or ""
assert "unknown_future_requirement" in remediate, (
f"unhinted token must surface in remediate as fallback. Got: {remediate!r}"
)
@pytest.mark.asyncio
async def test_acceptance_criteria_grouped_into_one_hint() -> None:
"""Acceptance criteria entries collapse into a single hint regardless
of how many criteria are missing (so 5 unaddressed criteria don't
produce 5 separate numbered items)."""
c = _build_choreographer()
task_id = uuid4()
env = await c._build_tracing_gap(
uuid4(),
task_id,
[
"acceptance_criterion:Branch named correctly",
"acceptance_criterion:Commit prefix present",
"acceptance_criterion:PR opened",
],
)
body = env.as_dict()
remediate = body["remediate"] or ""
# All three criterion names should appear in the SAME hint section
# (collapsed into one hint, not three separate numbered items).
assert "Branch named correctly" in remediate
assert "Commit prefix present" in remediate
assert "PR opened" in remediate
# Single-hint case → no "Multiple requirements" preamble.
assert "Multiple requirements missing" not in remediate