fix(board): nothing_to_propose exit for Board Program explorers + PR checks on slave-based PRs (#712)

* fix(board): give Board Program explorers a nothing_to_propose exit

Every propose_* verb requires at least one item, so an explorer that
legitimately found nothing — Barfly with no worthwhile X conversations,
Coroner with no autopsy subject — had no way to close its exploration
task. It declined, called i_am_idle(), and the task stayed PENDING
forever: the dispatcher re-matched it every tick and respawned the board
agent (~$0.61 a spawn, ~3 per 5-minute respawn-breaker cooldown window,
indefinitely), and BoardProgramEngine's one-open-cycle dedup wedged that
whole program shut, since the ledger row only closes once its exploration
task goes terminal.

nothing_to_propose(task_id, reason) is the explicit exit. task_id is
required rather than inferred: one explorer role owns several
independently-cadenced programs (head_marketing owns six) and each
assigns its exploration task to the same agent, so several are open at
once by design and guessing "the caller's oldest" completes the WRONG
cycle — stamping its reason onto an unrelated program's ledger while the
task actually being worked stays wedged. Resolution validates the named
task exists, carries a registered program source, is assigned to the
caller, and is non-terminal, then gates on the program's declared
explorer role from the registry, so a program registered later needs no
edit here.

The reason lands on board_program_cycles (migration 089) and renders into
the next cycle's LEARN context, replacing a bare "proposed 0, approved 0"
with why. That write runs in its own savepoint: it flushes on the same
session as the completion, and a bare try/except around a same-session
flush leaves the transaction pending-rollback, so a DB blip there would
discard the completion at the post-response commit while the verb
reported success.

All fourteen exploration prompts offer the exit, pinned by a
registry-parametrized test that fails when a future program is unwired.

* ci: fire PR checks on slave-based PRs, not master alone

All five gating workflows declared `pull_request: branches: [master]`,
but every fleet PR targets slave — cell->root, root->slave, and the CEO's
own. So `pull_request` never fired for any of them, and their only
coverage was the `push` trigger, which is gated on branch PREFIX
(feature/bug/chore/docs/hotfix). A branch named anything else got zero
checks — not a red run, an absent one — and a PR with no required check
present merges on a false green. PR #711 shipped that way on a `fix/`
branch.

Basing on the branch a PR merges INTO rather than what its head is named
makes coverage independent of branch naming, so a non-conforming prefix
can only ever cost the redundant push run, never the whole gate.

The same five also omitted slave from `push` (ci.yml aside, which added
it for the release gate's fail-closed CI read), so the panel suite, both
CodeQL analyses, and the e2e smoke never ran on the trunk master is cut
from.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-27 01:28:33 +02:00
committed by GitHub
co-authored by Renn F
parent a7b970a3b2
commit 42f8a5d18e
21 changed files with 1055 additions and 41 deletions
@@ -0,0 +1,311 @@
"""roboco.services.gateway.content_actions.nothing_to_propose — the generic
"this cycle found nothing worth proposing" exit for any Board Program
exploration task. Mirrors test_content_actions_barfly.py's mock-based shape;
registry-driven (not a hardcoded role set), so the role gate is exercised
against more than one program to prove it isn't a single frozenset."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``nothing_to_propose`` touches."""
def __init__(
self,
*,
source: str,
assigned_to: Any,
status: Any = TaskStatus.PENDING,
) -> None:
self.id = uuid4()
self.source = source
self.assigned_to = assigned_to
self.status = status
def _actions(role: str) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
notification_delivery=None,
)
return ContentActions(deps)
def _patch_task_lookup(actions: ContentActions, task: _FakeTask | None) -> None:
"""``nothing_to_propose`` now resolves the caller's task by EXPLICIT
task_id (``self.task.get(task_id)``), mirroring ``curate_vault`` —
no more guessing at "the caller's open task"."""
actions.task.get = AsyncMock(return_value=task)
def _patch_board_program_engine(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
engine = MagicMock()
engine.record_nothing_to_propose = AsyncMock()
monkeypatch.setattr(
"roboco.services.board_programs.get_board_program_engine", lambda _s: engine
)
return engine
REASON = "Reviewed the last 10 candidates; none were on-topic or worth a reply."
# --------------------------------------------------------------------------- #
# Reason validation
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_empty_reason() -> None:
env = await _actions("product_owner").nothing_to_propose(
agent_id=uuid4(), task_id=uuid4(), reason=""
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_trivial_reason() -> None:
env = await _actions("product_owner").nothing_to_propose(
agent_id=uuid4(), task_id=uuid4(), reason="wip"
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_too_short_reason() -> None:
env = await _actions("product_owner").nothing_to_propose(
agent_id=uuid4(), task_id=uuid4(), reason="nothing found"[:10]
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_oversized_reason() -> None:
env = await _actions("product_owner").nothing_to_propose(
agent_id=uuid4(), task_id=uuid4(), reason="x" * 801
)
assert env.error == "invalid_state"
assert "801" in (env.message or "")
# --------------------------------------------------------------------------- #
# Task resolution — task_id is REQUIRED and resolved by direct fetch-by-id,
# never guessed at from "the caller's open task" (see DEFECT 1: one role can
# own several open exploration tasks from different programs at once).
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_nothing_to_propose_missing_task_is_not_found() -> None:
actions = _actions("product_owner")
_patch_task_lookup(actions, None)
env = await actions.nothing_to_propose(
agent_id=uuid4(), task_id=uuid4(), reason=REASON
)
assert env.error == "not_found"
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_task_not_assigned_to_caller() -> None:
task = _FakeTask(source="board_barfly", assigned_to=uuid4())
actions = _actions("head_marketing")
_patch_task_lookup(actions, task)
env = await actions.nothing_to_propose(
agent_id=uuid4(), task_id=task.id, reason=REASON
)
assert env.error == "not_authorized"
assert "not assigned to you" in (env.message or "")
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_terminal_task() -> None:
agent_id = uuid4()
task = _FakeTask(
source="board_barfly", assigned_to=agent_id, status=TaskStatus.COMPLETED
)
actions = _actions("head_marketing")
_patch_task_lookup(actions, task)
env = await actions.nothing_to_propose(
agent_id=agent_id, task_id=task.id, reason=REASON
)
assert env.error == "invalid_state"
assert "completed" in (env.message or "")
# --------------------------------------------------------------------------- #
# Registry-driven role gate — proven against TWO different programs, not one
# hardcoded frozenset.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_nothing_to_propose_wrong_role_rejected_for_barfly() -> None:
"""barfly's declared role is head_marketing — a product_owner resolving
an (implausibly mis-assigned) barfly task is refused."""
agent_id = uuid4()
task = _FakeTask(source="board_barfly", assigned_to=agent_id)
actions = _actions("product_owner")
_patch_task_lookup(actions, task)
env = await actions.nothing_to_propose(
agent_id=agent_id, task_id=task.id, reason=REASON
)
assert env.error == "not_authorized"
assert "head_marketing" in (env.message or "")
@pytest.mark.asyncio
async def test_nothing_to_propose_wrong_role_rejected_for_pest_control() -> None:
"""pest_control's declared role is product_owner — a head_marketing
caller is refused, proving the gate is registry-driven per-task, not a
single fixed role."""
agent_id = uuid4()
task = _FakeTask(source="board_pest_control", assigned_to=agent_id)
actions = _actions("head_marketing")
_patch_task_lookup(actions, task)
env = await actions.nothing_to_propose(
agent_id=agent_id, task_id=task.id, reason=REASON
)
assert env.error == "not_authorized"
assert "product_owner" in (env.message or "")
@pytest.mark.asyncio
async def test_nothing_to_propose_unregistered_source_is_invalid_state() -> None:
"""Defensive: a task whose source isn't in PROGRAMS (unreachable via a
real spawn, which only ever assigns registered-program sources) fails
clean rather than crashing on a bare KeyError."""
agent_id = uuid4()
task = _FakeTask(source="not_a_real_program", assigned_to=agent_id)
actions = _actions("product_owner")
_patch_task_lookup(actions, task)
env = await actions.nothing_to_propose(
agent_id=agent_id, task_id=task.id, reason=REASON
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# Cross-program regression (DEFECT 1): the resolved task is ALWAYS the one
# named by task_id, proven by handing a task_id that belongs to a DIFFERENT
# program than the one a naive "caller's open task" guess would have picked.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_nothing_to_propose_completes_exactly_the_named_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
named = _FakeTask(source="board_megaphone", assigned_to=agent_id)
actions = _actions("head_marketing")
_patch_task_lookup(actions, named)
_patch_board_program_engine(monkeypatch)
actions.task.session.flush = AsyncMock()
env = await actions.nothing_to_propose(
agent_id=agent_id, task_id=named.id, reason=REASON
)
assert env.error is None, env.message
assert env.task_id == str(named.id)
assert env.context_briefing["program"] == "megaphone"
actions.task.get.assert_awaited_once_with(named.id)
# --------------------------------------------------------------------------- #
# Happy path — completes the task and records the LEARN reason
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_nothing_to_propose_happy_path_completes_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
task = _FakeTask(source="board_barfly", assigned_to=agent_id)
actions = _actions("head_marketing")
_patch_task_lookup(actions, task)
_patch_board_program_engine(monkeypatch)
actions.task.session.flush = AsyncMock()
env = await actions.nothing_to_propose(
agent_id=agent_id, task_id=task.id, reason=REASON
)
assert env.error is None, env.message
assert env.status == "nothing_to_propose"
assert env.task_id == str(task.id)
assert task.status == TaskStatus.COMPLETED
assert env.context_briefing["program"] == "barfly"
assert env.context_briefing["reason"] == REASON
@pytest.mark.asyncio
async def test_nothing_to_propose_records_learn_reason(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
task = _FakeTask(source="board_coroner", assigned_to=agent_id)
actions = _actions("auditor")
_patch_task_lookup(actions, task)
engine = _patch_board_program_engine(monkeypatch)
actions.task.session.flush = AsyncMock()
await actions.nothing_to_propose(agent_id=agent_id, task_id=task.id, reason=REASON)
engine.record_nothing_to_propose.assert_awaited_once_with(
"coroner", task.id, REASON
)
@pytest.mark.asyncio
async def test_nothing_to_propose_learn_record_failure_does_not_fail_verb(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A LEARN-ledger write failure is best-effort — the verb still
completes the task and returns ok, mirroring every other
record_decision producer's own best-effort wrapping. It is isolated in
its own savepoint (DEFECT 2), so this proves the failure alone, not
whether the completion would additionally survive a real commit — that
"does the outer transaction actually survive" guarantee needs a real DB
session and is proven in
test_nothing_to_propose_learn_failure_does_not_poison_completion
(tests/unit/services/test_board_program_engine.py)."""
agent_id = uuid4()
task = _FakeTask(source="board_barfly", assigned_to=agent_id)
actions = _actions("head_marketing")
_patch_task_lookup(actions, task)
engine = MagicMock()
engine.record_nothing_to_propose = AsyncMock(side_effect=RuntimeError("db down"))
monkeypatch.setattr(
"roboco.services.board_programs.get_board_program_engine", lambda _s: engine
)
actions.task.session.flush = AsyncMock()
env = await actions.nothing_to_propose(
agent_id=agent_id, task_id=task.id, reason=REASON
)
assert env.error is None, env.message
assert task.status == TaskStatus.COMPLETED
@@ -0,0 +1,81 @@
"""Every Board Program exploration prompt must mention nothing_to_propose —
the explicit "this cycle found nothing worth proposing" exit. A prompt that
never names the verb is dead code: the explorer never learns it exists, so a
genuinely empty cycle would still call i_am_idle() on a PENDING task and wedge
the program's LEARN dedup forever (see ContentActions.nothing_to_propose).
Parametrized over ``PROGRAMS`` itself (not a hardcoded list) — a newly
registered program with no entry in ``_PROMPT_BUILDERS`` below fails loudly,
so this test cannot silently go stale as the registry grows.
"""
from __future__ import annotations
from typing import Any, cast
from uuid import uuid4
import pytest
from roboco.foundation.policy.board_programs import PROGRAMS
from roboco.runtime.orchestrator import AgentOrchestrator
# program key -> the AgentOrchestrator prompt-builder method name. Every
# method here accepts a bare ``task`` dict (the LEARN/evidence context
# params all default to "").
_PROMPT_BUILDERS: dict[str, str] = {
"roadmap": "_build_roadmap_prompt",
"x_feature": "_build_feature_spotlight_prompt",
"pest_control": "_build_pest_control_prompt",
"periscope": "_build_periscope_prompt",
"coroner": "_build_coroner_prompt",
"sentinel": "_build_sentinel_prompt",
"spackle": "_build_spackle_prompt",
"scales": "_build_scales_prompt",
"mirror": "_build_mirror_prompt",
"megaphone": "_build_megaphone_prompt",
"librarian": "_build_librarian_prompt",
"war_room": "_build_war_room_prompt",
"barfly": "_build_barfly_prompt",
"dogfood": "_build_dogfood_prompt",
}
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
return orch
def _bare_task(source: str) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "exploration cycle",
"description": "x",
"source": source,
"orchestration_markers": None,
}
@pytest.mark.parametrize("program_key", sorted(PROGRAMS))
def test_every_board_program_prompt_mentions_nothing_to_propose(
program_key: str,
) -> None:
method_name = _PROMPT_BUILDERS.get(program_key)
assert method_name is not None, (
f"board program {program_key!r} has no entry in _PROMPT_BUILDERS — "
"wire its exploration prompt to mention nothing_to_propose() and add "
"it here"
)
orch = _make_orch()
builder = getattr(orch, method_name)
program = PROGRAMS[program_key]
prompt = builder(_bare_task(program.source))
assert "nothing_to_propose" in prompt, (
f"{method_name} never mentions nothing_to_propose() — a genuinely "
"empty cycle has no documented exit"
)
def test_prompt_builders_cover_exactly_the_registry() -> None:
assert set(_PROMPT_BUILDERS) == set(PROGRAMS)
@@ -10,6 +10,7 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, cast
from uuid import uuid4
import pytest
import pytest_asyncio
@@ -41,6 +42,7 @@ from roboco.models.base import (
)
from roboco.services import board_programs as bp_module
from roboco.services.board_programs import BoardProgramEngine
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
from roboco.services.task import (
BARFLY_SOURCE,
CORONER_SOURCE,
@@ -57,9 +59,10 @@ from roboco.services.task import (
WAR_ROOM_SOURCE,
X_FEATURE_EXPLORATION_SOURCE,
TaskCreateRequest,
TaskService,
get_task_service,
)
from sqlalchemy import delete, select, update
from sqlalchemy import delete, select, text, update
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
@@ -180,6 +183,169 @@ async def _make_exploration(
return task
# ---------------------------------------------------------------------------
# ContentActions.nothing_to_propose's task resolution — a real-DB regression
# suite. ``task_id`` is now REQUIRED and resolved by a direct fetch-by-id
# (mirrors ``curate_vault``), replacing the old ``get_open_board_program_
# exploration_task`` oldest-wins-across-programs query, which was unsound:
# one explorer role owns SEVERAL independently-cadenced programs at once
# (e.g. product_owner owns roadmap/pest_control/spackle/scales/dogfood), so
# several of an agent's exploration tasks are open simultaneously by design.
# The mock-based per-check envelope tests (reason validation, role gate,
# LEARN recording, best-effort failure) live in
# tests/unit/gateway/test_content_actions_nothing_to_propose.py; this suite
# proves the real DB resolution instead.
# ---------------------------------------------------------------------------
def _nothing_to_propose_actions(session: AsyncSession) -> ContentActions:
return ContentActions(
ContentActionsDeps(
task=TaskService(session),
git=None,
a2a=None,
journal=None,
workspace=None,
notifications=None,
)
)
@pytest.mark.asyncio
async def test_nothing_to_propose_resolves_named_task_not_older_sibling(
db_session: AsyncSession,
) -> None:
"""DEFECT regression: the SAME agent has two open exploration tasks from
two DIFFERENT programs at once (product_owner owns both roadmap and
pest_control) — nothing_to_propose(task_id=...) must complete the task
NAMED, never an older sibling from an unrelated program."""
await _seed(db_session)
older = await _make_exploration(db_session, source=ROADMAP_SOURCE)
older.created_at = datetime.now(UTC) - timedelta(hours=1)
await db_session.flush()
newer = await _make_exploration(db_session, source=PEST_CONTROL_SOURCE)
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
agent_id=PO_UUID,
task_id=cast("UUID", newer.id),
reason="checked recent rework/findings evidence; nothing rose to a bug",
)
assert env.error is None, env.message
assert env.task_id == str(newer.id)
assert env.context_briefing["program"] == "pest_control"
await db_session.refresh(newer)
await db_session.refresh(older)
assert newer.status == TS.COMPLETED
assert older.status == TS.PENDING
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_task_not_assigned_to_caller(
db_session: AsyncSession,
) -> None:
await _seed(db_session)
task = await _make_exploration(db_session, source=PEST_CONTROL_SOURCE)
other_agent = uuid4()
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
agent_id=other_agent,
task_id=cast("UUID", task.id),
reason="reviewed the candidate list; none were worth a reply",
)
assert env.error == "not_authorized"
await db_session.refresh(task)
assert task.status == TS.PENDING
@pytest.mark.asyncio
async def test_nothing_to_propose_rejects_terminal_task(
db_session: AsyncSession,
) -> None:
await _seed(db_session)
task = await _make_exploration(
db_session, source=PEST_CONTROL_SOURCE, status=TS.COMPLETED
)
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
agent_id=PO_UUID,
task_id=cast("UUID", task.id),
reason="reviewed the candidate list; none were worth a reply",
)
assert env.error == "invalid_state"
assert "completed" in (env.message or "")
@pytest.mark.asyncio
async def test_nothing_to_propose_missing_task_is_not_found(
db_session: AsyncSession,
) -> None:
await _seed(db_session)
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
agent_id=PO_UUID,
task_id=uuid4(),
reason="reviewed the candidate list; none were worth a reply",
)
assert env.error == "not_found"
@pytest.mark.asyncio
async def test_nothing_to_propose_learn_failure_does_not_poison_completion(
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""DEFECT 2 regression: a genuine DB-level failure inside the LEARN
write's own flush() must not poison the outer transaction — the task
completion flushed just before it must still survive the real commit
that follows (mirrors DbCommitMiddleware's post-response commit).
Uses an actual failing SQL statement, not a bare ``raise RuntimeError``
— a plain Python exception never touches the DBAPI connection, so it
would pass even without the ``begin_nested()`` savepoint fix and prove
nothing. Only a real aborted transaction exercises the isolation.
A real commit is unavoidable here — every other test in this module
relies on ``db_session``'s teardown rollback for isolation, so this test
deletes its own committed rows afterward (the ``_seed``-created project
has no idempotent-reuse guard, so a leaked commit collides with the next
test's ``_seed`` call on the unique project slug)."""
await _seed(db_session)
task = await _make_exploration(db_session, source=PEST_CONTROL_SOURCE)
class _PoisonedEngine:
async def record_nothing_to_propose(self, *_a: object, **_kw: object) -> None:
await db_session.execute(text("SELECT 1/0"))
monkeypatch.setattr(
bp_module, "get_board_program_engine", lambda _s: _PoisonedEngine()
)
env = await _nothing_to_propose_actions(db_session).nothing_to_propose(
agent_id=PO_UUID,
task_id=cast("UUID", task.id),
reason="reviewed the candidate list; none were worth a reply",
)
assert env.error is None, env.message
try:
# Without the savepoint, this commit would raise (the connection is
# still in Postgres's aborted-transaction state) and everything
# above, including the task completion, would be lost.
await db_session.commit()
refetched = await get_task_service(db_session).get(cast("UUID", task.id))
assert refetched is not None
assert refetched.status == TS.COMPLETED
finally:
await db_session.execute(delete(TaskTable).where(TaskTable.id == task.id))
await db_session.execute(delete(ProjectTable).where(ProjectTable.slug == SLUG))
await db_session.commit()
def _fake_originator(
holder: dict[str, TaskTable | None],
) -> Callable[[AsyncSession], Awaitable[TaskTable | None]]:
@@ -471,6 +637,83 @@ async def test_prior_cycle_context_renders_rejections_with_reasons(
assert "item-2 — too risky" in context
# ---------------------------------------------------------------------------
# record_nothing_to_propose — the nothing_to_propose verb's LEARN write.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_record_nothing_to_propose_sets_reason_without_touching_counters(
db_session: AsyncSession,
) -> None:
await _seed(db_session)
task = await _make_exploration(
db_session, source=BARFLY_SOURCE, status=TS.COMPLETED
)
db_session.add(
BoardProgramCycleTable(
program_key="barfly",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await db_session.flush()
engine = BoardProgramEngine(db_session)
await engine.record_nothing_to_propose(
"barfly", cast("UUID", task.id), "no worthwhile conversations this cycle"
)
row = await engine._latest_cycle("barfly")
assert row is not None
assert row.nothing_to_propose_reason == "no worthwhile conversations this cycle"
assert row.items_proposed == 0
assert row.items_approved == 0
assert row.items_rejected == 0
assert row.decisions == []
# Does not close the row itself — that's still _maybe_close's job.
assert row.closed_at is None
@pytest.mark.asyncio
async def test_record_nothing_to_propose_noop_when_no_cycle_row(
db_session: AsyncSession,
) -> None:
"""No cycle row exists for this program/task at all — a best-effort
no-op, mirroring record_decision's own producers' best-effort wrapping."""
engine = BoardProgramEngine(db_session)
await engine.record_nothing_to_propose("barfly", uuid4(), "no candidates")
@pytest.mark.asyncio
async def test_prior_cycle_context_renders_nothing_to_propose_reason(
db_session: AsyncSession,
) -> None:
"""The load-bearing render: a proposed-0 closed cycle with a recorded
reason shows WHY in the next cycle's LEARN context, not a bare
"proposed 0, approved 0"."""
await _seed(db_session)
task = await _make_exploration(
db_session, source=BARFLY_SOURCE, status=TS.COMPLETED
)
db_session.add(
BoardProgramCycleTable(
program_key="barfly",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
closed_at=datetime.now(UTC),
nothing_to_propose_reason="no worthwhile conversations this cycle",
)
)
await db_session.flush()
context = await BoardProgramEngine(db_session).prior_cycle_context("barfly")
assert (
"proposed 0 — nothing to propose: no worthwhile conversations this cycle"
in context
)
assert "proposed 0, approved 0" not in context
def test_learn_ref_names_the_item_not_its_per_cycle_index() -> None:
"""The ref reaches the next cycle's prompt, so it must say WHAT was
decided — ``item-1`` means something different in every cycle."""