mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(board): LEARN decisions name the item, not its per-cycle index A cycle's reject reasons are rendered into the NEXT cycle's exploration prompt, but the ref recorded alongside each reason was the item's stored id (item-0/item-1) — a per-cycle index that means something different every cycle and appears nowhere the explorer can resolve. The reason survived the loop; what it was about did not. Record the item's title instead, via a shared learn_ref() helper (falls back to the id when title-less, and reads target_task_title for Scales, whose items name the live task they mutate). * chore(lint): satisfy ruff 0.16 — keyword-only signatures and markdown formatting The dev toolchain resolved ruff 0.16.0, which stabilises PLR0917 (too many positional arguments) and formats python code blocks inside markdown. Both fired repo-wide and neither had anything to do with the code they flagged. - 36 signatures gain a `*` so their tail arguments are keyword-only, and the 104 call sites that passed them positionally are converted. mypy was the safety net for the static ones; the full suite caught nine more that only bind at runtime (the MCP tool functions, whose real callers already pass named JSON arguments). - 28 markdown files reformatted by 0.16's code-block formatter. - One RUF036 (`None` mid-union) autofixed in the GitLab provider. * fix(gateway): log the reason when a verb rejects A rejected envelope rides an HTTP 200, its body is never logged, and there is no trace table — so in the access log a verb an agent could not satisfy looks identical to one that worked. On 2026-07-25 four Board Programs (Periscope, Sentinel, Scales, Barfly) each POSTed their propose verb three or four times, persisted nothing, and left their exploration tasks PENDING; the reason was unrecoverable afterwards, from the logs or from the agents' own transcripts. Log error/message/remediate/missing plus the calling agent at envelope_to_response — the one chokepoint every v1 flow and do route returns through. Success envelopes stay silent. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""RoadmapService — the CEO's per-item approve/reject glue over a held
|
|
roadmap cycle.
|
|
|
|
The roadmap engine opens a HELD exploration task (``board_roadmap`` source);
|
|
the Product Owner authors the cycle onto it via ``propose_roadmap`` (a goal +
|
|
3-7 item drafts, persisted as a marker payload — see
|
|
``roboco.foundation.policy.content.markers.get_roadmap_cycle``). This service
|
|
is what the CEO-gated routes call: ``approve_item`` materializes one item as a
|
|
BACKLOG task (``source=roadmap``, via ``PrompterService.create_task_from_draft``
|
|
— CEO approval IS the confirmation); ``reject_item`` records the reason. Once
|
|
every item on the cycle is terminal (approved/rejected) the exploration task
|
|
itself completes. Both actions are idempotent per item.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from roboco.foundation.policy.board_programs import PROGRAMS, project_participates
|
|
from roboco.foundation.policy.content import markers
|
|
from roboco.models.base import TaskStatus
|
|
from roboco.services.base import BaseService
|
|
from roboco.services.board_programs import learn_ref
|
|
from roboco.services.task import ROADMAP_ITEM_SOURCE, ROADMAP_SOURCE, get_task_service
|
|
|
|
if TYPE_CHECKING:
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from roboco.db.tables import TaskTable
|
|
|
|
_TERMINAL_ITEM_STATUSES = ("approved", "rejected")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RoadmapItemResult:
|
|
"""Outcome of an approve/reject call on one roadmap item.
|
|
|
|
`status` is one of: approved, already_approved, rejected,
|
|
already_rejected, invalid_state.
|
|
"""
|
|
|
|
status: str
|
|
item_id: str
|
|
materialized_task_id: str | None
|
|
detail: str
|
|
|
|
|
|
class RoadmapService(BaseService):
|
|
"""List / approve / reject items within the open roadmap cycle(s)."""
|
|
|
|
service_name = "roadmap_service"
|
|
|
|
async def list_open_cycles(self) -> list[TaskTable]:
|
|
"""Every open (non-terminal) roadmap exploration task, authored or not."""
|
|
return await get_task_service(self.session).list_open_roadmap_cycles()
|
|
|
|
async def approve_item(
|
|
self, task_id: UUID, item_id: str, *, created_by: UUID
|
|
) -> RoadmapItemResult | None:
|
|
"""Materialize one proposed item as a BACKLOG task.
|
|
|
|
Returns None when ``task_id`` is not an open roadmap cycle or
|
|
``item_id`` does not exist on it. Idempotent: an already-approved item
|
|
returns its stored materialized task id without creating a duplicate.
|
|
An already-rejected item cannot be approved (surfaced as
|
|
``invalid_state`` — the CEO's reject already recorded a decision).
|
|
"""
|
|
task, payload, item = await self._find_item(task_id, item_id)
|
|
if task is None or payload is None or item is None:
|
|
return None
|
|
if item["status"] == "approved":
|
|
return RoadmapItemResult(
|
|
status="already_approved",
|
|
item_id=item_id,
|
|
materialized_task_id=item.get("materialized_task_id"),
|
|
detail="this item was already approved",
|
|
)
|
|
if item["status"] != "proposed":
|
|
return RoadmapItemResult(
|
|
status="invalid_state",
|
|
item_id=item_id,
|
|
materialized_task_id=None,
|
|
detail=f"item is {item['status']!r}, not proposed — cannot approve",
|
|
)
|
|
try:
|
|
new_task = await self._materialize(item, created_by=created_by)
|
|
except ValueError as exc:
|
|
return RoadmapItemResult(
|
|
status="invalid_state",
|
|
item_id=item_id,
|
|
materialized_task_id=None,
|
|
detail=str(exc),
|
|
)
|
|
item["status"] = "approved"
|
|
item["materialized_task_id"] = str(new_task.id)
|
|
markers.set_roadmap_cycle(task, payload)
|
|
self._maybe_complete_cycle(task, payload)
|
|
await self._record_learn(task, learn_ref(item), "approved")
|
|
await self.session.flush()
|
|
return RoadmapItemResult(
|
|
status="approved",
|
|
item_id=item_id,
|
|
materialized_task_id=str(new_task.id),
|
|
detail="materialized into the backlog",
|
|
)
|
|
|
|
async def reject_item(
|
|
self, task_id: UUID, item_id: str, reason: str
|
|
) -> RoadmapItemResult | None:
|
|
"""Record the CEO's reason for declining one item.
|
|
|
|
Idempotent: an already-rejected item returns its stored reason
|
|
without re-recording. An already-approved item cannot be rejected
|
|
(irreversible — a BACKLOG task already exists for it).
|
|
"""
|
|
task, payload, item = await self._find_item(task_id, item_id)
|
|
if task is None or payload is None or item is None:
|
|
return None
|
|
if item["status"] == "rejected":
|
|
return RoadmapItemResult(
|
|
status="already_rejected",
|
|
item_id=item_id,
|
|
materialized_task_id=None,
|
|
detail="this item was already rejected",
|
|
)
|
|
if item["status"] != "proposed":
|
|
return RoadmapItemResult(
|
|
status="invalid_state",
|
|
item_id=item_id,
|
|
materialized_task_id=item.get("materialized_task_id"),
|
|
detail=f"item is {item['status']!r}, not proposed — cannot reject",
|
|
)
|
|
item["status"] = "rejected"
|
|
item["reject_reason"] = reason
|
|
markers.set_roadmap_cycle(task, payload)
|
|
self._maybe_complete_cycle(task, payload)
|
|
await self._record_learn(task, learn_ref(item), "rejected", reason)
|
|
await self.session.flush()
|
|
return RoadmapItemResult(
|
|
status="rejected",
|
|
item_id=item_id,
|
|
materialized_task_id=None,
|
|
detail="recorded; feeds the next cycle's prompt",
|
|
)
|
|
|
|
async def _find_item(
|
|
self, task_id: UUID, item_id: str
|
|
) -> tuple[TaskTable | None, dict[str, Any] | None, dict[str, Any] | None]:
|
|
"""Resolve (exploration task, cycle payload, one item) or (None, None, None).
|
|
|
|
The payload is a DEEP COPY of the stored marker — mutating it (and the
|
|
returned ``item``) never touches ``task.orchestration_markers`` until
|
|
``markers.set_roadmap_cycle`` reassigns it. Mutating the live nested
|
|
dict in place first would poison SQLAlchemy's dirty-check: the
|
|
"unchanged" baseline it compares against is a reference to that same
|
|
mutable structure, not a snapshot, so an in-place edit followed by
|
|
reassignment can compare equal to itself and the UPDATE gets skipped.
|
|
"""
|
|
task = await get_task_service(self.session).get(task_id)
|
|
if task is None or task.source != ROADMAP_SOURCE:
|
|
return None, None, None
|
|
stored = markers.get_roadmap_cycle(task)
|
|
if stored is None:
|
|
return None, None, None
|
|
payload = copy.deepcopy(stored)
|
|
item = next(
|
|
(it for it in payload.get("items", []) if it.get("id") == item_id), None
|
|
)
|
|
if item is None:
|
|
return None, None, None
|
|
return task, payload, item
|
|
|
|
async def _materialize(
|
|
self, item: dict[str, Any], *, created_by: UUID
|
|
) -> TaskTable:
|
|
"""Turn one approved item draft into a real BACKLOG task."""
|
|
from roboco.services.project import get_project_service
|
|
from roboco.services.prompter import get_prompter_service
|
|
|
|
project = await get_project_service(self.session).get_by_slug(
|
|
item["project_slug"]
|
|
)
|
|
if project is None or project.id is None:
|
|
raise ValueError(f"unknown project slug: {item['project_slug']!r}")
|
|
if not project_participates(PROGRAMS["roadmap"], project.board_programs):
|
|
self.log.warning(
|
|
"roadmap: materialize skipped — project excluded (!roadmap)",
|
|
project_slug=item["project_slug"],
|
|
)
|
|
raise ValueError(
|
|
f"project {item['project_slug']!r} is excluded from the roadmap program"
|
|
)
|
|
draft = {
|
|
"title": item["title"],
|
|
"objective": item["description"],
|
|
"notes": [f"Rationale: {item['rationale']}"],
|
|
"acceptance_criteria": item["acceptance_criteria"],
|
|
"project_id": str(project.id),
|
|
"team": item["team"],
|
|
"priority": item.get("priority", 2),
|
|
"source": ROADMAP_ITEM_SOURCE,
|
|
}
|
|
return await get_prompter_service(self.session).create_task_from_draft(
|
|
draft,
|
|
created_by,
|
|
status=TaskStatus.BACKLOG,
|
|
)
|
|
|
|
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
|
|
"""Complete the exploration task once every item is terminal."""
|
|
items = payload.get("items") or []
|
|
if items and all(it.get("status") in _TERMINAL_ITEM_STATUSES for it in items):
|
|
from_status = (
|
|
task.status.value
|
|
if isinstance(task.status, TaskStatus)
|
|
else str(task.status)
|
|
)
|
|
task.status = TaskStatus.COMPLETED
|
|
get_task_service(self.session)._emit_status_transition_audit(
|
|
task,
|
|
from_status=from_status,
|
|
to_status=TaskStatus.COMPLETED.value,
|
|
agent_role=None,
|
|
audit_agent_id=None,
|
|
)
|
|
|
|
async def _record_learn(
|
|
self, task: TaskTable, item_ref: str, verdict: str, reason: str | None = None
|
|
) -> None:
|
|
"""Best-effort LEARN: a record_decision failure must never break the
|
|
CEO's approve/reject — mirrors the vault-writer best-effort seams.
|
|
|
|
Targets ``task`` (this exploration task) by id — exact attribution
|
|
even when a newer cycle for "roadmap" has since opened, unlike
|
|
``record_decision``'s most-recent fallback.
|
|
"""
|
|
try:
|
|
from roboco.services.board_programs import get_board_program_engine
|
|
|
|
await get_board_program_engine(self.session).record_decision(
|
|
"roadmap",
|
|
item_ref,
|
|
verdict,
|
|
reason,
|
|
exploration_task_id=cast("UUID", task.id),
|
|
)
|
|
except Exception:
|
|
self.log.warning("roadmap: LEARN record_decision failed (best-effort)")
|
|
|
|
|
|
def get_roadmap_service(session: AsyncSession) -> RoadmapService:
|
|
"""Construct a RoadmapService bound to ``session``."""
|
|
return RoadmapService(session)
|