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>
585 lines
25 KiB
Python
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)
|