fix(api): carry orchestration_markers in the task response

TaskResponse declares the field, but task_to_response never assigned it,
so every GET /tasks response served null. The orchestrator fetches its
work over that endpoint and builds prompts from the returned dict, so
all twelve marker reads in orchestrator.py resolved to `{}`.

Live symptom: a Barfly explorer was told "SCREENED CANDIDATES: (none)"
and correctly refused to invent a tweet — while BarflyEngine only opens
an exploration task when it HAS candidates, so the ones it gathered were
sitting on the row the whole time. The gateway path reads markers off the
ORM object directly, which is why propose_conversation_replies could see
what the prompt could not.

Same blindness applied to every other marker consumer on the dispatch
path: the spotlight's seen-features dedup, pest control's evidence
snapshot, coroner's autopsy subject, and the dev-redispatch's
original_developer lookup.
This commit is contained in:
Renn F
2026-07-27 01:35:18 +02:00
parent 42f8a5d18e
commit 48feb033d6
2 changed files with 49 additions and 2 deletions
+43 -2
View File
@@ -42,7 +42,7 @@ from roboco.models.base import (
Team,
)
from roboco.models.product import ProductCellMapping
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.runtime.orchestrator import AgentOrchestrator, _format_barfly_candidates
_ORDER_DEFAULT = 0
@@ -312,7 +312,9 @@ def test_task_update_budget_usd_rejects_negative() -> None:
# ---------------------------------------------------------------------------
def _stub_task(*, with_project: bool = False) -> Any:
def _stub_task(
*, with_project: bool = False, markers: dict[str, Any] | None = None
) -> Any:
"""Build a TaskTable stand-in that matches task_to_response's reads."""
return SimpleNamespace(
id=uuid4(),
@@ -360,6 +362,7 @@ def _stub_task(*, with_project: bool = False) -> Any:
doc_notes=None,
quick_context=None,
notes_structured=None,
orchestration_markers=markers,
self_verified=False,
qa_verified=None,
branch_name=None,
@@ -368,6 +371,44 @@ def _stub_task(*, with_project: bool = False) -> Any:
)
def _response_for(stub: Any) -> Any:
fake_inspector = MagicMock()
fake_inspector.unloaded = {"project"}
with patch("roboco.api.schemas.tasks.sa_inspect", return_value=fake_inspector):
return task_to_response(stub)
def test_task_to_response_carries_orchestration_markers() -> None:
"""The field is declared on TaskResponse, so omitting it from the builder
served null instead of failing loudly."""
stub = _stub_task(markers={"barfly_candidates": [{"id": "1"}]})
assert _response_for(stub).orchestration_markers == {
"barfly_candidates": [{"id": "1"}]
}
def test_task_to_response_markers_reach_a_board_program_prompt() -> None:
"""The regression this closes end-to-end: the orchestrator fetches work
over GET /tasks and renders prompts from that dict, so a dropped marker
showed a Barfly explorer "(none)" for candidates its engine HAD gathered
— and since the engine opens no task at all when the candidate list is
empty, the explorer could only ever conclude there was nothing to do."""
candidate = {
"id": "1955",
"author_handle": "someone",
"text": "how do you keep AI agents from stepping on each other?",
"engagement_note": "42 likes",
}
stub = _stub_task(markers={"barfly_candidates": [candidate]})
dispatched = _response_for(stub).model_dump()
rendered = _format_barfly_candidates(dispatched["orchestration_markers"] or {})
assert rendered != "(none)"
assert "id=1955" in rendered
assert "how do you keep AI agents" in rendered
def test_task_to_response_omits_slug_when_project_not_loaded() -> None:
stub = _stub_task(with_project=False)
fake_inspector = MagicMock()