feat(gateway): propagate sessions to subtasks + auto-emit milestone progress

Task #156 (sessions): pre-gateway flow created a session for the whole
task tree at once, so subtasks were visible in the PM's group chat the
moment they existed. The gateway creates subtasks one-by-one via
delegate(), losing that wiring. Added
MessagingService.propagate_sessions_to_subtask and threaded it through
the choreographer's _create_subtask_from_inputs. ChoreographerDeps grew
an optional `messaging` field so existing test wirings keep working.

Task #155 (progress): smoke-9 had zero progress entries because the dev
never called progress() explicitly. Added _record_milestone_progress and
fire it server-side from two natural milestones — open_pr ("opened PR
#N", 70%) and i_am_done ("submitted for QA review", 90%). Best-effort
write (contextlib.suppress) so a progress failure cannot break the verb
path. Extracted _open_pr_success_envelope to keep cyclomatic rank ≤ B.
This commit is contained in:
Renn F
2026-05-15 06:54:35 +02:00
parent 4fdde2b082
commit 2c838c2a9e
6 changed files with 425 additions and 6 deletions
@@ -0,0 +1,138 @@
"""Task #156: delegate() must thread parent sessions onto the new subtask.
Pre-gateway flow created sessions for whole task trees at once, so subtasks
were visible in the group chat the PM was already using. The gateway's
delegate() creates subtasks one-by-one — without this step the new agent
spawns into an empty channel and can't see the PM's prior discussion.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.choreographer._impl import DelegateInputs
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
"messaging": AsyncMock(),
}
base.update(overrides)
task = base["task"]
task.session = MagicMock()
task.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
repo = base["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 = []
_ldef = base["journal"].latest_decision_at.return_value
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
return ChoreographerDeps(**base)
@pytest.mark.asyncio
async def test_create_subtask_propagates_parent_sessions() -> None:
"""When the choreographer creates a subtask, the parent's session
links are auto-attached to it via MessagingService.propagate_sessions_to_subtask.
"""
pm_id = uuid4()
parent_id = uuid4()
new_task_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
team="backend",
status="in_progress",
task_type="planning",
sequence=0,
assigned_to=pm_id,
)
new_task = MagicMock(id=new_task_id, status="pending")
task_svc = AsyncMock()
task_svc.create_subtask.return_value = new_task
messaging = AsyncMock()
messaging.propagate_sessions_to_subtask.return_value = []
deps = _make_deps(task=task_svc, messaging=messaging)
c = Choreographer(deps)
inputs = DelegateInputs(
title="Backend slice",
description="API + DB",
acceptance_criteria=["api works", "schema migrated"],
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
result = await c._create_subtask_from_inputs(pm_id, parent_id, parent, inputs)
assert result is new_task
messaging.propagate_sessions_to_subtask.assert_awaited_once_with(
parent_task_id=parent_id,
subtask_id=new_task_id,
added_by=pm_id,
)
@pytest.mark.asyncio
async def test_create_subtask_no_messaging_skips_propagation() -> None:
"""When messaging dep is None (e.g. lightweight test wiring), the
subtask is still created — propagation is a soft enhancement, not a
hard requirement."""
pm_id = uuid4()
parent_id = uuid4()
new_task_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
team="backend",
status="in_progress",
task_type="planning",
sequence=0,
assigned_to=pm_id,
)
new_task = MagicMock(id=new_task_id, status="pending")
task_svc = AsyncMock()
task_svc.create_subtask.return_value = new_task
deps = _make_deps(task=task_svc, messaging=None)
c = Choreographer(deps)
inputs = DelegateInputs(
title="Backend slice",
description="API + DB",
acceptance_criteria=["api works"],
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
estimated_complexity="medium",
)
# Must not raise even though messaging is None.
result = await c._create_subtask_from_inputs(pm_id, parent_id, parent, inputs)
assert result is new_task
@@ -0,0 +1,68 @@
"""Task #155: server-side auto-progress on lifecycle milestones.
Smoke-9 ended with zero progress entries because the dev never called
progress() explicitly. The fix: server emits progress entries at
deterministic lifecycle milestones (open_pr → "opened PR #N",
i_am_done → "submitted for QA review") so the panel + audit have entries
regardless of agent chattiness. Progress is observability — write failures
must not break the verb path.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
@pytest.mark.asyncio
async def test_record_milestone_progress_calls_add_progress() -> None:
"""The helper proxies to TaskService.add_progress with the right args."""
task_svc = AsyncMock()
deps = ChoreographerDeps(
task=task_svc,
work_session=AsyncMock(),
git=AsyncMock(),
a2a=AsyncMock(),
journal=AsyncMock(),
audit=AsyncMock(),
evidence_repo=AsyncMock(),
messaging=AsyncMock(),
)
c = Choreographer(deps)
task_id = uuid4()
agent_id = uuid4()
await c._record_milestone_progress(task_id, agent_id, "opened PR #20", 70)
task_svc.add_progress.assert_awaited_once_with(
task_id=task_id,
agent_id=agent_id,
message="opened PR #20",
percentage=70,
)
@pytest.mark.asyncio
async def test_record_milestone_progress_swallows_errors() -> None:
"""Progress is observability. A failing add_progress must not raise
so the verb body's main flow is unaffected."""
task_svc = AsyncMock()
task_svc.add_progress.side_effect = RuntimeError("db lock contention")
deps = ChoreographerDeps(
task=task_svc,
work_session=AsyncMock(),
git=AsyncMock(),
a2a=AsyncMock(),
journal=AsyncMock(),
audit=AsyncMock(),
evidence_repo=AsyncMock(),
messaging=AsyncMock(),
)
c = Choreographer(deps)
# Must NOT raise.
await c._record_milestone_progress(uuid4(), uuid4(), "submitted for QA review", 90)
task_svc.add_progress.assert_awaited_once()
@@ -0,0 +1,98 @@
"""Task #156: MessagingService.propagate_sessions_to_subtask.
Tests the helper's call shape and idempotency contract without going
through the DB. Integration coverage (real DB, real linking) lives in
``tests/integration/test_messaging_service.py``.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.models.session import SessionTaskRelationshipType
from roboco.services.messaging import MessagingService
def _link(session_id: object, relationship_type: str) -> MagicMock:
link = MagicMock()
link.session_id = session_id
link.relationship_type = relationship_type
return link
@pytest.mark.asyncio
async def test_propagate_links_every_parent_session_to_subtask() -> None:
"""Every link on the parent gets re-attached to the new subtask."""
svc = MessagingService.__new__(MessagingService)
parent_session = uuid4()
review_session = uuid4()
svc.get_sessions_for_task = AsyncMock( # type: ignore[method-assign]
return_value=[
_link(parent_session, "discussion"),
_link(review_session, "review"),
]
)
calls: list[dict[str, Any]] = []
async def fake_link(**kwargs: Any) -> Any:
calls.append(kwargs)
link = MagicMock()
link.session_id = kwargs["session_id"]
link.task_id = kwargs["task_id"]
return link
svc.link_session_to_task = fake_link # type: ignore[method-assign]
parent_id = uuid4()
subtask_id = uuid4()
added_by = uuid4()
expected_sessions = {parent_session, review_session}
out = await svc.propagate_sessions_to_subtask(parent_id, subtask_id, added_by)
assert len(out) == len(expected_sessions)
assert {c["session_id"] for c in calls} == expected_sessions
# Every propagated link must be non-primary — primary is the subtask's
# own slot, never inherited from the parent.
assert all(c["is_primary"] is False for c in calls)
# Every call must target the new subtask (not the parent).
assert {c["task_id"] for c in calls} == {subtask_id}
# Relationship types preserved.
rels = {c["relationship_type"] for c in calls}
assert SessionTaskRelationshipType.DISCUSSION in rels
assert SessionTaskRelationshipType.REVIEW in rels
@pytest.mark.asyncio
async def test_propagate_no_parent_sessions_returns_empty() -> None:
"""When the parent has no session links, propagation is a no-op."""
svc = MessagingService.__new__(MessagingService)
svc.get_sessions_for_task = AsyncMock(return_value=[]) # type: ignore[method-assign]
svc.link_session_to_task = AsyncMock() # type: ignore[method-assign]
out = await svc.propagate_sessions_to_subtask(uuid4(), uuid4(), uuid4())
assert out == []
svc.link_session_to_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_propagate_unknown_relationship_type_defaults_to_discussion() -> None:
"""Garbage relationship_type on the parent link doesn't crash; it
defaults to DISCUSSION so the subtask is still linked."""
svc = MessagingService.__new__(MessagingService)
svc.get_sessions_for_task = AsyncMock( # type: ignore[method-assign]
return_value=[_link(uuid4(), "definitely-not-a-real-type")]
)
calls: list[dict[str, Any]] = []
async def fake_link(**kwargs: Any) -> Any:
calls.append(kwargs)
return MagicMock()
svc.link_session_to_task = fake_link # type: ignore[method-assign]
await svc.propagate_sessions_to_subtask(uuid4(), uuid4(), uuid4())
assert len(calls) == 1
assert calls[0]["relationship_type"] == SessionTaskRelationshipType.DISCUSSION