Files
roboco/roboco/services/board_programs.py
T
879afc14a4 Board Program LEARN context, ruff 0.16, and verb-rejection observability (#700)
* 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>
2026-07-26 15:07:28 +02:00

585 lines
25 KiB
Python

"""BoardProgramEngine — the generic trigger/dedup/originate/LEARN engine every
Board Program registry entry (``roboco.foundation.policy.board_programs``)
rides, replacing per-engine loops + dedup ledgers.
``run_due_programs`` is what the orchestrator's ``_board_program_loop`` calls
on a tick: for each CRON program, check enabled, dedup against the
``board_program_cycles`` ledger, check the cron interval, and — on due —
delegate origination to the program's proven callable (``RoadmapEngine.
run_cycle`` / ``XEngine.open_feature_spotlight_exploration``). This engine
never authors content itself, same posture as the engines it wraps.
Dedup note: an open ledger row (``closed_at IS NULL``) blocks a new cycle,
but a row is only a REAL block while its exploration task is still
non-terminal. A task going terminal (COMPLETED/CANCELLED) — the roadmap
service's own "every item decided" rule, or the X engine completing the
exploration the instant ``propose_feature_spotlight`` runs — auto-closes the
row on the next check. Without this, a ledger row can outlive the condition
it was tracking (e.g. an x_feature exploration completes at propose time,
long before the CEO decides the materialized draft) and would otherwise wedge
the program's dedup forever, a regression from the per-engine dedup this
replaces.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from sqlalchemy import func, select
from roboco.config import settings
from roboco.db.tables import BoardProgramCycleTable, ProjectTable, TaskTable
from roboco.foundation.policy.board_programs import (
PROGRAMS,
TriggerKind,
program_due,
project_participates,
)
from roboco.models.base import TaskStatus
from roboco.services.base import BaseService
from roboco.services.settings import get_settings_service
from roboco.services.task import get_task_service
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.foundation.policy.board_programs import BoardProgram
_TERMINAL_STATUSES = (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
async def _originate_roadmap(session: AsyncSession) -> TaskTable | None:
from roboco.services.roadmap_engine import get_roadmap_engine
return await get_roadmap_engine(session).run_cycle()
async def _originate_x_feature(session: AsyncSession) -> TaskTable | None:
from roboco.services.x_engine import get_x_engine
return await get_x_engine(session).open_feature_spotlight_exploration()
async def _originate_pest_control(session: AsyncSession) -> TaskTable | None:
from roboco.services.pest_control_engine import get_pest_control_engine
return await get_pest_control_engine(session).run_cycle()
async def _originate_periscope(session: AsyncSession) -> TaskTable | None:
from roboco.services.periscope_engine import get_periscope_engine
return await get_periscope_engine(session).run_cycle()
async def _originate_scales(session: AsyncSession) -> TaskTable | None:
from roboco.services.scales_engine import get_scales_engine
return await get_scales_engine(session).run_cycle()
async def _originate_coroner(_session: AsyncSession) -> TaskTable | None:
"""Coroner is EVENT-triggered (spec §4) — ``run_due_programs`` skips every
non-CRON program before it would ever call this (see ``program_due``), so
this always-None stub only exists to keep ``_ORIGINATORS`` covering the
registry 1:1 (asserted by tests). A real cycle opens through
``CoronerEngine.open_for_incident``, called directly from the bounce/
cancel/budget-block hooks — it bypasses this dict entirely, building its
own ``BoardProgramCycleTable`` row the same way ``_originate_and_record``
does below, since there is no loop tick to route it through."""
return None
async def _originate_sentinel(session: AsyncSession) -> TaskTable | None:
from roboco.services.sentinel_engine import get_sentinel_engine
return await get_sentinel_engine(session).run_cycle()
async def _originate_spackle(session: AsyncSession) -> TaskTable | None:
from roboco.services.spackle_engine import get_spackle_engine
return await get_spackle_engine(session).run_cycle()
async def _originate_mirror(session: AsyncSession) -> TaskTable | None:
from roboco.services.mirror_engine import get_mirror_engine
return await get_mirror_engine(session).run_cycle()
async def _originate_megaphone(session: AsyncSession) -> TaskTable | None:
from roboco.services.megaphone_engine import get_megaphone_engine
return await get_megaphone_engine(session).run_cycle()
async def _originate_librarian(session: AsyncSession) -> TaskTable | None:
from roboco.services.librarian_engine import get_librarian_engine
return await get_librarian_engine(session).run_cycle()
async def _originate_war_room(session: AsyncSession) -> TaskTable | None:
"""War Room's ``_ORIGINATORS`` binding — a REAL originator, unlike
``_originate_coroner``'s always-None stub. War Room is EVENT-triggered
same as Coroner (``run_due_programs`` never calls this — see the
trigger-kind guard there), but its "run now" / CEO on-demand path
(``BoardProgramEngine.open_program_cycle``, which does NOT check trigger
kind) genuinely drives a fresh blank-brief cycle through
``WarRoomEngine.run_cycle``. The release-publish hook bypasses this dict
entirely via ``WarRoomEngine.open_for_release`` (mirrors
``CoronerEngine.open_for_incident``)."""
from roboco.services.war_room_engine import get_war_room_engine
return await get_war_room_engine(session).run_cycle()
async def _originate_barfly(session: AsyncSession) -> TaskTable | None:
from roboco.services.barfly_engine import get_barfly_engine
return await get_barfly_engine(session).run_cycle()
async def _originate_dogfood(session: AsyncSession) -> TaskTable | None:
"""Unlike Coroner's never-firing stub, this is a REAL originator: a
Dogfood cycle needs no external incident id, just the next opted-in
project in rotation, so both the release-publish hook and a CEO "run
now" call open a cycle through the ordinary ``open_program_cycle`` path.
The cron loop still never reaches this — ``program_due`` refuses any
non-CRON trigger before ``_run_due_one`` would call it — see
``roboco.services.dogfood_engine``'s module docstring."""
from roboco.services.dogfood_engine import get_dogfood_engine
return await get_dogfood_engine(session).run_cycle()
# Origination bindings live here, not in the pure foundation registry — one
# entry per PROGRAMS key, asserted by tests. Each program's ``source`` is
# separately asserted equal to the service-layer constant it duplicates
# (ROADMAP_SOURCE, X_FEATURE_EXPLORATION_SOURCE, PEST_CONTROL_SOURCE,
# PERISCOPE_SOURCE, CORONER_SOURCE, SENTINEL_SOURCE, SPACKLE_SOURCE,
# SCALES_SOURCE, MIRROR_SOURCE, MEGAPHONE_SOURCE, LIBRARIAN_SOURCE,
# WAR_ROOM_SOURCE, BARFLY_SOURCE, DOGFOOD_SOURCE) so the two can't drift.
_ORIGINATORS: dict[str, Callable[[AsyncSession], Awaitable[TaskTable | None]]] = {
"roadmap": _originate_roadmap,
"x_feature": _originate_x_feature,
"pest_control": _originate_pest_control,
"periscope": _originate_periscope,
"coroner": _originate_coroner,
"sentinel": _originate_sentinel,
"spackle": _originate_spackle,
"scales": _originate_scales,
"mirror": _originate_mirror,
"megaphone": _originate_megaphone,
"librarian": _originate_librarian,
"war_room": _originate_war_room,
"barfly": _originate_barfly,
"dogfood": _originate_dogfood,
}
# Sentinel "last explored" timestamp for a project a rotation has never
# targeted — older than any real ``opened_at``, so it always sorts first.
_NEVER_EXPLORED = datetime.min.replace(tzinfo=UTC)
async def pick_rotation_target(
session: AsyncSession, projects: list[ProjectTable], *, source: str
) -> ProjectTable:
"""The opted-in project due this cycle for a project-scoped program's
round-robin: never-explored beats explored, else the oldest
last-explored timestamp wins — ties (including every never-explored
project) break by ``projects``' own deterministic order
(``BoardProgramEngine.opted_in_projects``' ORDER BY). ``source`` is the
program's own exploration-task source tag (e.g. ``PEST_CONTROL_SOURCE``),
read via ``_last_explored_at`` rather than the LEARN ledger — see that
function's docstring. Shared by every project-scoped program's rotation
(Pest Control, Spackle, Mirror, Dogfood) so they rotate identically."""
if len(projects) == 1:
return projects[0]
last_explored = await _last_explored_at(session, source)
return min(
projects,
key=lambda p: (
p.id in last_explored,
last_explored.get(cast("UUID", p.id), _NEVER_EXPLORED),
),
)
async def _last_explored_at(session: AsyncSession, source: str) -> dict[UUID, datetime]:
"""Most recent exploration task's ``created_at`` per project id, for a
given task ``source`` — a project-scoped rotation's memory of which
opted-in project went last. Reads the exploration tasks themselves
rather than the LEARN ledger (``board_program_cycles``): that ledger is
only populated by a ``BoardProgramEngine``-mediated call
(``open_program_cycle`` / ``run_due_programs``), so keying off it would
leave the rotation blind whenever an engine's own ``run_cycle`` runs
directly. Every prior cycle is guaranteed terminal by the time this
runs — the one-open-cycle dedup in each engine's ``run_cycle`` already
refused a new cycle while any project's exploration task was still
open."""
result = await session.execute(
select(TaskTable.project_id, func.max(TaskTable.created_at))
.where(TaskTable.source == source)
.group_by(TaskTable.project_id)
)
return {pid: created for pid, created in result.all() if pid is not None}
async def _pest_control_rework_spike(session: AsyncSession) -> bool:
"""True when the trailing-7-day rework rate crosses
``settings.pest_rework_threshold`` — the "metric" half of Pest Control's
"weekly cron OR rework-rate spike" trigger (spec §4). Mirrors the
strategy-engine idle-trigger pattern rather than a new TriggerKind: the
program's own trigger stays CRON, and this predicate lets a cycle open
off-schedule on top of that cadence."""
from roboco.services.metrics import get_metrics_service
report = await get_metrics_service(session).get_rework_metrics(days=7)
return report.rate > settings.pest_rework_threshold
# Metric-predicate bindings, mirroring ``_ORIGINATORS`` — one entry per
# program that wants an off-schedule accelerator on top of its CRON cadence.
# Absent from this dict = cron-only (every program but pest_control today).
_METRIC_PREDICATES: dict[str, Callable[[AsyncSession], Awaitable[bool]]] = {
"pest_control": _pest_control_rework_spike,
}
def learn_ref(item: dict[str, Any], limit: int = 80) -> str:
"""The ``item_ref`` a per-item approve/reject records for LEARN.
The item's own title, because the ref's only consumer is
``_render_cycle``, whose output goes into the NEXT cycle's exploration
prompt. Passing the stored ``id`` instead (``item-0``/``item-1``, a
per-cycle index — see ``_normalize_roadmap_item``) rendered
"rejected: item-1 — <reason>": the CEO's reason survived, but nothing
said which proposal it was about, and the index means something
different in every cycle. Falls back to the id when a title is missing.
``target_task_title`` covers Scales, whose items name the live task they
mutate rather than carrying a draft title of their own.
"""
title = str(item.get("title") or item.get("target_task_title") or "").strip()
ref = title or str(item.get("id") or "")
return ref[:limit].rstrip() if len(ref) > limit else ref
def _legacy_enabled(key: str) -> bool:
"""The pre-registry flag(s) each program aliases while both exist."""
if key == "roadmap":
return settings.roadmap_engine_enabled
if key == "x_feature":
return settings.x_engine_enabled and settings.x_feature_spotlight_enabled
return False
def _interval_override(key: str) -> int | None:
"""Per-program configured cadence, when the operator has one set."""
if key == "roadmap":
return settings.roadmap_interval_seconds
if key == "x_feature":
return settings.x_feature_spotlight_interval_seconds
return None
async def program_armed(session: AsyncSession, key: str) -> bool:
"""Whether program ``key`` is armed: the settings-store per-program
override when a row exists, else the legacy boot flag(s) it aliases.
THE single chokepoint every origination gate must route through —
``BoardProgramEngine.enabled`` (below), ``RoadmapEngine.run_cycle``,
``XEngine.open_feature_spotlight_exploration``, and — via
``BoardProgramEngine.open_program_cycle`` — the strategy-engine's idle
trigger. Before this existed, ``run_cycle``/``open_feature_spotlight_
exploration`` re-checked their OWN legacy flag internally instead of
this resolver, so a settings-store-True + legacy-False combination (the
exact state the shipped panel toggle produces) silently originated
nothing forever.
"""
return await get_settings_service(session).get_bool(
f"board_program.{key}.enabled", _legacy_enabled(key)
)
class BoardProgramEngine(BaseService):
"""Trigger/dedup/originate/LEARN over every registered Board Program."""
service_name = "board_program_engine"
async def enabled(self, key: str) -> bool:
"""Per-program settings-store override, else the legacy flag."""
return await program_armed(self.session, key)
async def run_due_programs(self) -> list[str]:
"""Originate a cycle for every enabled, due CRON program, PLUS every
program whose metric predicate (``_METRIC_PREDICATES``) fires this
tick even off-schedule.
Returns the keys that opened a new cycle. One program's failure — cron
or metric — is logged and never blocks the rest, mirrors the CI-watch
sweep. A metric hit for a program already opened by the cron pass is
a cheap no-op (``open_program_cycle`` re-checks dedup itself).
"""
opened: list[str] = []
now = datetime.now(UTC)
for key, program in PROGRAMS.items():
if program.trigger is not TriggerKind.CRON:
continue
try:
if await self._run_due_one(key, now):
opened.append(key)
except Exception:
self.log.exception("board-program cycle failed", program=key)
await self._run_due_metric_predicates(opened)
return opened
async def _run_due_metric_predicates(self, opened: list[str]) -> None:
"""Append to ``opened`` any program whose metric predicate fires this
tick, off-schedule — split out of ``run_due_programs`` to keep its
complexity down (xenon budget).
Cheap gates first: scope (any project opted in?) and dedup (already
an open cycle?) are checked BEFORE the predicate runs, so a predicate
that costs several queries (e.g. the rework-rate check's 8-11 query
``MetricsService`` call) is never evaluated on a tick that would have
been rejected anyway — a spike-prone metric shouldn't pay full price
every tick just to be discarded by a guard it was always going to
fail.
"""
for key, predicate in _METRIC_PREDICATES.items():
if key in opened or not await self.enabled(key):
continue
program = PROGRAMS[key]
try:
if not await self._scope_gate(program):
continue
blocked, _ = await self._dedup_state(key)
if blocked:
continue
if await predicate(self.session) and (
await self.open_program_cycle(key) is not None
):
opened.append(key)
except Exception:
self.log.exception("board-program metric predicate failed", program=key)
async def _run_due_one(self, key: str, now: datetime) -> bool:
if not await self.enabled(key):
return False
program = PROGRAMS[key]
if not await self._scope_gate(program):
return False
blocked, last_opened_at = await self._dedup_state(key)
if blocked:
return False
if not program_due(
program,
now=now,
last_opened_at=last_opened_at,
interval_override=_interval_override(key),
):
return False
return await self._originate_and_record(key) is not None
async def open_program_cycle(self, key: str) -> TaskTable | None:
"""Originate a cycle for ``key`` off-schedule (enabled + dedup only,
no cron-due check) — the strategy-engine trigger + "run now" seam."""
if key not in PROGRAMS or not await self.enabled(key):
return None
program = PROGRAMS[key]
if not await self._scope_gate(program):
return None
blocked, _ = await self._dedup_state(key)
if blocked:
return None
return await self._originate_and_record(key)
async def _scope_gate(self, program: BoardProgram) -> bool:
"""Project-scoped programs need at least one opted-in project before
a cycle is worth opening; org-scoped programs have no run-side gate
(their scoping is output-side only — see ``project_participates``)."""
if program.scope != "project":
return True
if await self.opted_in_projects(program):
return True
self.log.info(
"board-program: no project opted in, skipping cycle", program=program.key
)
return False
async def opted_in_projects(self, program: BoardProgram) -> list[ProjectTable]:
"""Active projects where ``project_participates(program, ...)`` holds,
deterministically ordered (created_at, then id) — callers that pick
a single project out of this list (e.g. ``PestControlEngine``'s
rotation) need a stable order, not whatever Postgres happens to
return without an ORDER BY."""
result = await self.session.execute(
select(ProjectTable)
.where(ProjectTable.is_active.is_(True))
.order_by(ProjectTable.created_at, ProjectTable.id)
)
return [
p
for p in result.scalars().all()
if project_participates(program, p.board_programs)
]
async def cycle_state(self, key: str) -> tuple[bool, datetime | None]:
"""(open_cycle, last_opened_at) — reconciled via the same auto-close
dedup logic ``run_due_programs``/``open_program_cycle`` consult, so a
reader (the API/panel) sees exactly what a "run now" call would."""
return await self._dedup_state(key)
async def record_decision(
self,
program_key: str,
item_ref: str,
verdict: str,
reason: str | None = None,
*,
exploration_task_id: UUID | None = None,
) -> None:
"""Accrue one CEO approve/reject onto a cycle for this program.
When ``exploration_task_id`` is given, targets the cycle row for
THAT exploration task exactly (regardless of open/closed) — the
caller holds the real originating task in hand (e.g. RoadmapService,
reading the item off its own exploration task), so attribution stays
exact even when a newer cycle has since opened for the same program
(e.g. the CEO's decision lands after the original cycle auto-closed
with undecided items — the admin-cancel edge — and a fresh cycle
already opened in the meantime). Falls back to the most RECENT cycle
(open or closed) when omitted or unresolved — x_post_service's X
drafts don't carry their originating exploration task id, so this is
the original, unchanged fallback for that caller. A best-effort
no-op when no matching cycle exists.
"""
cycle = None
if exploration_task_id is not None:
cycle = await self._cycle_for_exploration(program_key, exploration_task_id)
if cycle is None:
cycle = await self._latest_cycle(program_key)
if cycle is None:
return
cycle.items_proposed += 1
if verdict == "approved":
cycle.items_approved += 1
else:
cycle.items_rejected += 1
cycle.decisions = [
*cycle.decisions,
{"item_ref": item_ref, "verdict": verdict, "reason": reason},
]
if cycle.closed_at is None:
await self._maybe_close(cycle)
await self.session.flush()
async def prior_cycle_context(self, program_key: str, limit: int = 2) -> str:
"""Render the last ``limit`` CLOSED cycles for prompt injection, oldest
first; empty string when none exist yet."""
result = await self.session.execute(
select(BoardProgramCycleTable)
.where(
BoardProgramCycleTable.program_key == program_key,
BoardProgramCycleTable.closed_at.isnot(None),
)
.order_by(BoardProgramCycleTable.closed_at.desc())
.limit(limit)
)
cycles = list(result.scalars().all())
if not cycles:
return ""
return "\n".join(self._render_cycle(c) for c in reversed(cycles))
def _render_cycle(self, cycle: BoardProgramCycleTable) -> str:
line = f"proposed {cycle.items_proposed}, approved {cycle.items_approved}"
rejected = [d for d in cycle.decisions if d.get("verdict") == "rejected"]
reasons = "; ".join(
f"{d.get('item_ref')}{d.get('reason')}"
for d in rejected
if d.get("reason")
)
if reasons:
line += f"; rejected: {reasons}"
return line
# ---- dedup / ledger plumbing --------------------------------------
async def _dedup_state(self, key: str) -> tuple[bool, datetime | None]:
"""(blocked, last_opened_at) — blocked when a still-genuinely-open
cycle row exists after an attempted auto-close."""
latest = await self._latest_cycle(key)
if latest is None:
return False, None
if latest.closed_at is None and not await self._maybe_close(latest):
return True, latest.opened_at
return False, latest.opened_at
async def _maybe_close(self, cycle: BoardProgramCycleTable) -> bool:
"""Close ``cycle`` when its exploration task is terminal (or gone);
returns whether it is now closed."""
if cycle.exploration_task_id is None:
cycle.closed_at = datetime.now(UTC)
await self.session.flush()
return True
task = await get_task_service(self.session).get(
cast("UUID", cycle.exploration_task_id)
)
if task is None or task.status in _TERMINAL_STATUSES:
cycle.closed_at = datetime.now(UTC)
await self.session.flush()
return True
return False
async def _latest_cycle(self, key: str) -> BoardProgramCycleTable | None:
result = await self.session.execute(
select(BoardProgramCycleTable)
.where(BoardProgramCycleTable.program_key == key)
.order_by(BoardProgramCycleTable.opened_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def _cycle_for_exploration(
self, key: str, exploration_task_id: UUID
) -> BoardProgramCycleTable | None:
"""The cycle row that opened FOR this exact exploration task, or None
— used by ``record_decision`` for exact attribution over the
most-recent fallback."""
result = await self.session.execute(
select(BoardProgramCycleTable)
.where(
BoardProgramCycleTable.program_key == key,
BoardProgramCycleTable.exploration_task_id == exploration_task_id,
)
.limit(1)
)
return result.scalar_one_or_none()
async def _originate_and_record(self, key: str) -> TaskTable | None:
task = await _ORIGINATORS[key](self.session)
if task is None:
return None
self.session.add(
BoardProgramCycleTable(
program_key=key,
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await self.session.flush()
return task
def get_board_program_engine(session: AsyncSession) -> BoardProgramEngine:
"""Construct a BoardProgramEngine bound to ``session``."""
return BoardProgramEngine(session)