mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -53,6 +53,14 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
# Every fleet PR targets the dev branch, not master (cell->root and
|
||||
# root->slave alike). Listing master alone meant `pull_request` never
|
||||
# fired for them, so their ONLY coverage was the `push` trigger above
|
||||
# — which is branch-PREFIX gated, silently leaving any head outside
|
||||
# feature/bug/chore/docs/hotfix with zero checks rather than a red
|
||||
# one. Basing on the target branch (what the PR merges INTO) instead
|
||||
# of the head's name makes coverage independent of branch naming.
|
||||
- slave
|
||||
paths:
|
||||
- 'roboco/**'
|
||||
- 'agents/**'
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
# merge API (see ci.yml for the live-proven receipts); `push` does, so
|
||||
# it's the redundant trigger for a required check that must not go
|
||||
# ABSENT on a fleet-authored PR revision.
|
||||
branches: [master, 'feature/**', 'bug/**', 'chore/**', 'docs/**', 'hotfix/**']
|
||||
branches: [master, slave, 'feature/**', 'bug/**', 'chore/**', 'docs/**', 'hotfix/**']
|
||||
paths:
|
||||
- 'roboco/**'
|
||||
- 'agents/**'
|
||||
@@ -16,7 +16,8 @@ on:
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/code-ql.yml'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
# Fleet PRs target the dev branch — see ci.yml's pull_request note.
|
||||
branches: [master, slave]
|
||||
paths:
|
||||
- 'roboco/**'
|
||||
- 'agents/**'
|
||||
|
||||
@@ -7,12 +7,13 @@ on:
|
||||
# merge API (see ci.yml for the live-proven receipts); `push` does, so
|
||||
# it's the redundant trigger for a required check that must not go
|
||||
# ABSENT on a fleet-authored PR revision.
|
||||
branches: [master, 'feature/**', 'bug/**', 'chore/**', 'docs/**', 'hotfix/**']
|
||||
branches: [master, slave, 'feature/**', 'bug/**', 'chore/**', 'docs/**', 'hotfix/**']
|
||||
paths:
|
||||
- 'panel/**'
|
||||
- '.github/workflows/codeql-js-ts.yml'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
# Fleet PRs target the dev branch — see ci.yml's pull_request note.
|
||||
branches: [master, slave]
|
||||
paths:
|
||||
- 'panel/**'
|
||||
- '.github/workflows/codeql-js-ts.yml'
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# The dev branch: every fleet PR merges here, so without it the smoke
|
||||
# suite never ran on the trunk that master is cut from.
|
||||
- slave
|
||||
# Fleet task branches: `pull_request`'s synchronize trigger doesn't
|
||||
# reliably fire when a revision lands on a PR head via the merge API
|
||||
# (see ci.yml for the live-proven receipts); `push` does.
|
||||
@@ -23,6 +26,8 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
# Fleet PRs target the dev branch — see ci.yml's pull_request note.
|
||||
- slave
|
||||
paths:
|
||||
- 'roboco/**'
|
||||
- 'alembic/**'
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# The dev branch: every fleet PR merges here, so without it the panel
|
||||
# suite never ran on the trunk that master is cut from.
|
||||
- slave
|
||||
# Fleet task branches: `pull_request`'s synchronize trigger doesn't
|
||||
# reliably fire when a revision lands on a PR head via the merge API
|
||||
# (see ci.yml for the live-proven receipts); `push` does.
|
||||
@@ -18,6 +21,8 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
# Fleet PRs target the dev branch — see ci.yml's pull_request note.
|
||||
- slave
|
||||
paths:
|
||||
- 'panel/**'
|
||||
- '.github/workflows/panel-ci.yml'
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
|
||||
| `curate_vault` | `curate_vault(task_id: UUID, narrative: str)` |
|
||||
| `propose_postmortem` | `propose_postmortem(incident_summary: str, root_cause: str, failed_stage: str, process_change: ProcessChangeInput, playbook: PostmortemPlaybookInput | None = None)` |
|
||||
| `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `propose_quality_report` | `propose_quality_report(headline: str, items: list[QualityReportItemInput], overall_assessment: str)` |
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
|
||||
@@ -202,6 +202,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
@@ -232,6 +233,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `dm` | `dm(recipient: str, text: str, task_id: UUID | None = None, skill: str | None = None)` |
|
||||
| `notify` | `notify(target: str, text: str, priority: str = 'normal', task_id: UUID | None = None)` |
|
||||
| `evidence` | `evidence(task_id: UUID)` |
|
||||
| `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
@@ -267,6 +269,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `archive_playbook` | `archive_playbook(playbook_id: UUID)` |
|
||||
| `curate_vault` | `curate_vault(task_id: UUID, narrative: str)` |
|
||||
| `propose_postmortem` | `propose_postmortem(incident_summary: str, root_cause: str, failed_stage: str, process_change: ProcessChangeInput, playbook: PostmortemPlaybookInput | None = None)` |
|
||||
| `nothing_to_propose` | `nothing_to_propose(task_id: UUID, reason: str)` |
|
||||
| `notify_list` | `notify_list(unread_only: bool = True, pending_ack_only: bool = False, limit: int = 20)` |
|
||||
| `notify_get` | `notify_get(notification_id: UUID)` |
|
||||
| `propose_quality_report` | `propose_quality_report(headline: str, items: list[QualityReportItemInput], overall_assessment: str)` |
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add board_program_cycles.nothing_to_propose_reason.
|
||||
|
||||
The ``nothing_to_propose`` do-verb — a Board Program explorer's explicit
|
||||
"this cycle found nothing worth proposing" exit, distinct from the
|
||||
per-item-decision ``decisions`` column — needs somewhere to park its reason
|
||||
so ``BoardProgramEngine._render_cycle`` can surface WHY a cycle proposed
|
||||
zero items in the next cycle's LEARN context, instead of a bare "proposed 0,
|
||||
approved 0". Nullable; unset for every pre-existing row.
|
||||
|
||||
Revision ID: 089_board_cycle_ntp_reason
|
||||
Revises: 088_project_board_programs
|
||||
Create Date: 2026-07-26
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "089_board_cycle_ntp_reason"
|
||||
down_revision = "088_project_board_programs"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"board_program_cycles",
|
||||
sa.Column("nothing_to_propose_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("board_program_cycles", "nothing_to_propose_reason")
|
||||
@@ -20,6 +20,7 @@ from roboco.api.schemas.v1.do import (
|
||||
DraftPlaybookRequest,
|
||||
EvidenceRequest,
|
||||
NoteRequest,
|
||||
NothingToProposeRequest,
|
||||
NotifyAckRequest,
|
||||
NotifyGetRequest,
|
||||
NotifyListRequest,
|
||||
@@ -460,6 +461,26 @@ async def do_propose_video(
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/nothing_to_propose")
|
||||
@guard_deco.rate_limit(requests=20, window=60)
|
||||
@guard_deco.max_request_size(size_bytes=65536)
|
||||
@guard_deco.custom_validation(secret_exfil_validator)
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.behavior_analysis(_RUNAWAY_RULES)
|
||||
async def do_nothing_to_propose(
|
||||
request: Request,
|
||||
body: NothingToProposeRequest,
|
||||
x_agent_id: _AgentIdHeader,
|
||||
actions: _ContentActionsDep,
|
||||
) -> dict:
|
||||
env = await actions.nothing_to_propose(
|
||||
agent_id=x_agent_id,
|
||||
task_id=body.task_id,
|
||||
reason=body.reason,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@router.post("/dm")
|
||||
@guard_deco.rate_limit(requests=60, window=60)
|
||||
@guard_deco.max_request_size(size_bytes=65536)
|
||||
|
||||
@@ -395,6 +395,17 @@ class ProposeVideoRequest(BaseModel):
|
||||
input_props: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class NothingToProposeRequest(BaseModel):
|
||||
"""Any board explorer's "this cycle found nothing worth proposing" exit
|
||||
— a substantive reason explaining what was looked at and why none of it
|
||||
qualified. ``task_id`` names the caller's own exploration task explicitly
|
||||
(one board role can own several open cycles from different programs at
|
||||
once) — which program/role it resolves against comes from THAT task."""
|
||||
|
||||
task_id: UUID
|
||||
reason: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class DmRequest(BaseModel):
|
||||
recipient: str # agent slug
|
||||
text: str = Field(..., min_length=1)
|
||||
|
||||
@@ -2596,6 +2596,10 @@ class BoardProgramCycleTable(Base):
|
||||
decisions: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, nullable=False, default=list
|
||||
)
|
||||
# Set only by the ``nothing_to_propose`` do-verb — an explorer's
|
||||
# "genuinely nothing worth proposing this cycle" verdict, distinct from
|
||||
# ``decisions`` (per-item CEO approve/reject). Read by ``_render_cycle``.
|
||||
nothing_to_propose_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -984,6 +984,29 @@ def propose_video(
|
||||
)
|
||||
|
||||
|
||||
def nothing_to_propose(task_id: str, reason: str) -> dict[str, Any]:
|
||||
"""Board explorer: this cycle genuinely found nothing worth proposing.
|
||||
|
||||
Call this instead of the program's own propose_* verb when you've done
|
||||
the real exploration and there is truly nothing that qualifies — every
|
||||
propose_* verb requires at least one item, so without this your task
|
||||
would stay stuck open forever. Completes your open exploration task
|
||||
immediately (no per-item CEO queue); your reason is carried into the
|
||||
next cycle's briefing so the org remembers what you already checked.
|
||||
|
||||
Args:
|
||||
task_id: The exploration task's own id — printed as "TASK: <id>" at
|
||||
the top of your prompt. Pass it verbatim; one board role can own
|
||||
several open exploration cycles from different programs at once,
|
||||
so this call only ever completes THIS task, never a guess.
|
||||
reason: What you looked at and why none of it qualified this cycle
|
||||
(>=15 chars, substantive — not a placeholder).
|
||||
"""
|
||||
return _post(
|
||||
"/api/v1/do/nothing_to_propose", {"task_id": task_id, "reason": reason}
|
||||
)
|
||||
|
||||
|
||||
def dm(
|
||||
recipient: str,
|
||||
text: str,
|
||||
@@ -1314,6 +1337,7 @@ _TOOLS: dict[str, Any] = {
|
||||
"propose_playbook_drafts": propose_playbook_drafts,
|
||||
"propose_campaign": propose_campaign,
|
||||
"propose_video": propose_video,
|
||||
"nothing_to_propose": nothing_to_propose,
|
||||
"dm": dm,
|
||||
"notify": notify,
|
||||
"evidence": evidence,
|
||||
|
||||
+106
-35
@@ -16366,9 +16366,15 @@ involved in this cycle.
|
||||
item is an object with: title, description, acceptance_criteria (list
|
||||
of strings), project_slug, team ('backend'|'frontend'|'ux_ui'),
|
||||
priority (1-4, default 2), rationale (why this, why now).
|
||||
5. i_am_idle() — once proposed. The CEO reviews and approves/rejects each
|
||||
item individually in the roadmap queue; an approved item lands in BACKLOG
|
||||
for normal PM activation — nothing here auto-starts.
|
||||
|
||||
If nothing you explored is worth a themed cycle at all this week, call
|
||||
nothing_to_propose(task_id="{task_id}", reason="<what you explored and
|
||||
why none of it warranted a cycle>") instead — a forced, thin cycle is
|
||||
worse than an honest skip, and the next cycle's briefing will see your
|
||||
reason.
|
||||
5. i_am_idle() — once proposed (or declined). The CEO reviews and approves/
|
||||
rejects each item individually in the roadmap queue; an approved item
|
||||
lands in BACKLOG for normal PM activation — nothing here auto-starts.
|
||||
|
||||
Do NOT claim, plan, delegate, or attempt to start any of the items yourself —
|
||||
that is not your job here, and the gateway will reject those verbs.
|
||||
@@ -16426,9 +16432,14 @@ author this alone.
|
||||
strings), project_slug, team ('backend'|'frontend'|'ux_ui'), priority
|
||||
(1-4, default 2), evidence (REQUIRED — the file:line / ledger row /
|
||||
metric that justifies this as a real bug; no evidence, no item).
|
||||
6. i_am_idle() — once proposed. The CEO reviews and approves/rejects each
|
||||
item individually in the pest-control queue; an approved item lands in
|
||||
BACKLOG for normal PM activation — nothing here auto-starts.
|
||||
|
||||
If the evidence above and your own grep turned up no REAL, LIVE bug this
|
||||
cycle, call nothing_to_propose(task_id="{task_id}", reason="<what you
|
||||
checked and why nothing qualified>") instead — an invented bug is worse
|
||||
than an honest miss.
|
||||
6. i_am_idle() — once proposed (or declined). The CEO reviews and approves/
|
||||
rejects each item individually in the pest-control queue; an approved
|
||||
item lands in BACKLOG for normal PM activation — nothing here auto-starts.
|
||||
|
||||
Do NOT claim, plan, delegate, fix anything yourself, or attempt to start any
|
||||
of the items — that is not your job here, and the gateway will reject those
|
||||
@@ -16487,9 +16498,14 @@ deletions. You author this alone.
|
||||
action ('reprioritize' or 'cancel'), new_priority (int 0-3, REQUIRED
|
||||
iff action is 'reprioritize' — 0 is P0/highest, 3 is P3/lowest),
|
||||
rationale (REQUIRED — why this task should change).
|
||||
6. i_am_idle() — once proposed. The CEO reviews and approves/rejects each
|
||||
item individually in the Scales queue; approval MUTATES the live task in
|
||||
place — nothing here changes anything itself.
|
||||
|
||||
If the stale-backlog snapshot has nothing genuinely worth rebalancing
|
||||
this cycle, call nothing_to_propose(task_id="{task_id}", reason="<what
|
||||
you reviewed and why nothing warranted a change>") instead — churning
|
||||
the backlog for its own sake is worse than leaving it alone.
|
||||
6. i_am_idle() — once proposed (or declined). The CEO reviews and approves/
|
||||
rejects each item individually in the Scales queue; approval MUTATES the
|
||||
live task in place — nothing here changes anything itself.
|
||||
|
||||
Do NOT cancel, reprioritize, claim, plan, or delegate anything yourself —
|
||||
that is not your job here, and the gateway will reject those verbs. You only
|
||||
@@ -16542,8 +16558,14 @@ INCIDENT: {title!r} ({incident_task_id}) — {kind}
|
||||
into the pending-playbook curation queue), 'prompt_fix',
|
||||
'conventions_rule', or 'other'. Propose ONE change — the smallest
|
||||
thing that would have caught or prevented this.
|
||||
6. i_am_idle() — once proposed. This completes the autopsy immediately and
|
||||
notifies the CEO; there is no per-item queue to leave open.
|
||||
|
||||
If the evidence genuinely supports no systemic process change — a true
|
||||
one-off with no lesson the org can act on — call nothing_to_propose(
|
||||
task_id="{task_id}", reason="<what you found and why no process change
|
||||
is warranted>") instead of forcing one.
|
||||
6. i_am_idle() — once proposed (or declined). This completes the autopsy
|
||||
immediately and notifies the CEO; there is no per-item queue to leave
|
||||
open.
|
||||
|
||||
Do NOT message the fleet about this — you stay silent to other agents; your
|
||||
output is this postmortem and your journal, both CEO-facing.
|
||||
@@ -16606,8 +16628,13 @@ follow-up -> spotlight) — for the CEO to review. You author this alone.
|
||||
see the VOICE GUIDE), publish_after (ISO 8601 datetime, future,
|
||||
strictly ascending across posts), stage_label (one of 'teaser',
|
||||
'launch', 'follow_up', 'spotlight', 'other').
|
||||
6. i_am_idle() — once proposed. This completes your planning cycle
|
||||
immediately; the CEO reviews, edits, approves, or rejects each post
|
||||
|
||||
If there is genuinely nothing this cycle worth a campaign — no real
|
||||
highlights on a release trigger, nothing fresh on an on-demand run —
|
||||
call nothing_to_propose(task_id="{task_id}", reason="<what you checked
|
||||
and why no campaign qualifies>") instead of forcing a weak arc.
|
||||
6. i_am_idle() — once proposed (or declined). This completes your planning
|
||||
cycle immediately; the CEO reviews, edits, approves, or rejects each post
|
||||
individually in the X post queue — you never post anything yourself.
|
||||
|
||||
Do NOT claim, plan, delegate, or attempt to post anything yourself — that is
|
||||
@@ -16665,9 +16692,14 @@ the CEO to review; you author this alone.
|
||||
(1-4, default 2), evidence (REQUIRED — must name BOTH sides of the
|
||||
gap, e.g. the route that exists and the panel surface that doesn't;
|
||||
no evidence, no item).
|
||||
5. i_am_idle() — once proposed. The CEO reviews and approves/rejects each
|
||||
item individually in the spackle queue; an approved item lands in
|
||||
BACKLOG for normal PM activation — nothing here auto-starts.
|
||||
|
||||
If your inventory comparison turned up no REAL, LIVE gap this cycle,
|
||||
call nothing_to_propose(task_id="{task_id}", reason="<what you compared
|
||||
and why nothing qualified>") instead — a manufactured gap is worse than
|
||||
an honest miss.
|
||||
5. i_am_idle() — once proposed (or declined). The CEO reviews and approves/
|
||||
rejects each item individually in the spackle queue; an approved item
|
||||
lands in BACKLOG for normal PM activation — nothing here auto-starts.
|
||||
|
||||
Do NOT claim, plan, delegate, fix anything yourself, or attempt to start any
|
||||
of the items — that is not your job here, and the gateway will reject those
|
||||
@@ -16727,9 +16759,15 @@ review; you author this alone.
|
||||
strings), project_slug, team ('backend'|'frontend'|'ux_ui'), priority
|
||||
(1-4, default 2), evidence (REQUIRED — must name BOTH the drifted
|
||||
claim and the reality it contradicts; no evidence, no item).
|
||||
5. i_am_idle() — once proposed. The CEO reviews and approves/rejects each
|
||||
item individually in the mirror queue; an approved item lands in BACKLOG
|
||||
as a docs task for normal PM activation — nothing here auto-starts.
|
||||
|
||||
If the messaging matches shipped reality with no drift worth a fix this
|
||||
cycle, call nothing_to_propose(task_id="{task_id}", reason="<what you
|
||||
compared and why nothing qualified>") instead — a manufactured drift is
|
||||
worse than an honest miss.
|
||||
5. i_am_idle() — once proposed (or declined). The CEO reviews and approves/
|
||||
rejects each item individually in the mirror queue; an approved item
|
||||
lands in BACKLOG as a docs task for normal PM activation — nothing here
|
||||
auto-starts.
|
||||
|
||||
Do NOT claim, plan, delegate, fix anything yourself, or attempt to start any
|
||||
of the items — that is not your job here, and the gateway will reject those
|
||||
@@ -16795,9 +16833,14 @@ or wrong. You author this alone.
|
||||
(1-4, default 2), evidence (REQUIRED — the actual walked path: which
|
||||
pages, which clicks, what broke or felt wrong, in prose; NEVER a
|
||||
screenshot; no evidence, no item).
|
||||
6. i_am_idle() — once proposed. The CEO reviews and approves/rejects each
|
||||
item individually in the dogfood queue; an approved item lands in
|
||||
BACKLOG for normal PM activation — nothing here auto-starts.
|
||||
|
||||
If your walk turned up nothing genuinely broken or confusing this cycle,
|
||||
call nothing_to_propose(task_id="{task_id}", reason="<what you walked
|
||||
and why nothing qualified>") instead — a manufactured friction item is
|
||||
worse than an honest "it worked fine".
|
||||
6. i_am_idle() — once proposed (or declined). The CEO reviews and approves/
|
||||
rejects each item individually in the dogfood queue; an approved item
|
||||
lands in BACKLOG for normal PM activation — nothing here auto-starts.
|
||||
|
||||
Do NOT claim, plan, delegate, fix anything yourself, or attempt to start any
|
||||
of the items — that is not your job here, and the gateway will reject those
|
||||
@@ -16859,7 +16902,10 @@ RECENTLY REJECTED BY THE CEO — avoid repeating these angles: {rejected_line}
|
||||
propose_feature_spotlight(skip=True, skip_reason="<why nothing qualifies>")
|
||||
instead — a weak, forced spotlight is worse than skipping a cycle, and the
|
||||
next cycle will see this skip as recent activity (the cadence won't just
|
||||
re-fire into the same quiet period tomorrow).
|
||||
re-fire into the same quiet period tomorrow). nothing_to_propose(
|
||||
task_id="{task_id}", reason="<same explanation>") does the identical job
|
||||
and is the standard exit every other board program uses — either call is
|
||||
fine for this cycle.
|
||||
6. i_am_idle() — once proposed (or skipped). The CEO reviews, edits, approves,
|
||||
or rejects the draft in the X post queue; nothing posts without that
|
||||
explicit approval.
|
||||
@@ -16912,8 +16958,13 @@ claim in a real source.
|
||||
http(s) URL), relevance (why this matters to us). threats/
|
||||
opportunities are optional lists of up to 5 short notes each;
|
||||
positioning_note is an optional note on a shift worth acting on.
|
||||
5. i_am_idle() — once filed. The CEO reads the brief as a report in the
|
||||
panel; nothing here needs your further attention.
|
||||
|
||||
If your research turned up no citable finding worth a brief this cycle,
|
||||
call nothing_to_propose(task_id="{task_id}", reason="<what you
|
||||
researched and why nothing was citable>") instead — an uncited or
|
||||
invented finding is worse than filing nothing.
|
||||
5. i_am_idle() — once filed (or declined). The CEO reads the brief as a
|
||||
report in the panel; nothing here needs your further attention.
|
||||
|
||||
Do NOT claim, plan, delegate, or attempt to act on anything you find
|
||||
yourself — that is not your job here, and the gateway will reject those.
|
||||
@@ -16967,9 +17018,14 @@ SCREENED CANDIDATES:
|
||||
object with: tweet_id (REQUIRED — must be one of the candidate ids
|
||||
above, verbatim), reply_body (the reply text, <=280 chars), rationale
|
||||
(REQUIRED — why this conversation is worth replying to).
|
||||
5. i_am_idle() — once proposed. Each reply materializes its own held draft in
|
||||
the X post queue; the CEO reviews, edits, approves, or rejects each one
|
||||
individually — nothing here posts anything itself.
|
||||
|
||||
If none of the screened candidates are genuinely worth a reply this
|
||||
cycle, call nothing_to_propose(task_id="{task_id}", reason="<what you
|
||||
reviewed and why none qualified>") instead — a forced, low-value reply
|
||||
is worse than no reply.
|
||||
5. i_am_idle() — once proposed (or declined). Each reply materializes its
|
||||
own held draft in the X post queue; the CEO reviews, edits, approves, or
|
||||
rejects each one individually — nothing here posts anything itself.
|
||||
|
||||
Do NOT claim, plan, delegate, or attempt to post anything yourself — that is
|
||||
not your job here, and the gateway will reject those.
|
||||
@@ -17031,8 +17087,13 @@ You stay silent to the fleet throughout — this report goes to the CEO only.
|
||||
(the ledger row / metric / file that backs it), suggested_action
|
||||
(what should happen next — a later "convert to task" step, not
|
||||
something you do yourself).
|
||||
6. i_am_idle() — once filed. The CEO reads the report in the panel; nothing
|
||||
here needs your further attention.
|
||||
|
||||
If the evidence above shows no real drift worth naming this cycle, call
|
||||
nothing_to_propose(task_id="{task_id}", reason="<what you reviewed and
|
||||
why nothing rose to a finding>") instead — a manufactured drift signal
|
||||
is worse than an honest "no drift this cycle".
|
||||
6. i_am_idle() — once filed (or declined). The CEO reads the report in the
|
||||
panel; nothing here needs your further attention.
|
||||
|
||||
Do NOT claim, plan, delegate, fix anything yourself, message any other
|
||||
agent, or attempt to act on anything you find — that is not your job here,
|
||||
@@ -17088,9 +17149,13 @@ there is no separate approval surface.
|
||||
5. propose_editorial_post(angle="<one of the four above>", body="<the post>",
|
||||
rationale="<why this angle, this cycle>")
|
||||
— call this EXACTLY ONCE.
|
||||
6. i_am_idle() — once proposed. The CEO reviews, edits, approves, or rejects
|
||||
the draft in the X post queue; nothing posts without that explicit
|
||||
approval.
|
||||
|
||||
If the digest above has genuinely nothing worth an editorial post this
|
||||
cycle, call nothing_to_propose(task_id="{task_id}", reason="<what you
|
||||
reviewed and why no angle qualified>") instead of forcing a thin post.
|
||||
6. i_am_idle() — once proposed (or declined). The CEO reviews, edits,
|
||||
approves, or rejects the draft in the X post queue; nothing posts
|
||||
without that explicit approval.
|
||||
|
||||
Do NOT claim, plan, delegate, or attempt to post anything yourself — that is
|
||||
not your job here, and the gateway will reject those.
|
||||
@@ -17159,8 +17224,14 @@ cycle curates them, never this same call).
|
||||
(REQUIRED, <=500 chars — which repeated journal/learning pattern
|
||||
justifies this playbook; a draft without it is noise, and the verb
|
||||
rejects it).
|
||||
6. i_am_idle() — once filed. The drafts sit in the normal pending-playbook
|
||||
curation queue; nothing here needs your further attention this cycle.
|
||||
|
||||
If the mining context above shows no genuinely repeated, undrafted
|
||||
pattern this cycle, call nothing_to_propose(task_id="{task_id}",
|
||||
reason="<what you mined and why nothing qualified>") instead — a one-off
|
||||
dressed up as a pattern is worse than an honest miss.
|
||||
6. i_am_idle() — once filed (or declined). The drafts sit in the normal
|
||||
pending-playbook curation queue; nothing here needs your further
|
||||
attention this cycle.
|
||||
|
||||
Do NOT claim, plan, delegate, fix anything yourself, message any other
|
||||
agent, or call draft_playbook (you don't have it — use
|
||||
|
||||
@@ -482,6 +482,27 @@ class BoardProgramEngine(BaseService):
|
||||
await self._maybe_close(cycle)
|
||||
await self.session.flush()
|
||||
|
||||
async def record_nothing_to_propose(
|
||||
self, program_key: str, exploration_task_id: UUID, reason: str
|
||||
) -> None:
|
||||
"""Record an explorer's "genuinely nothing worth proposing this
|
||||
cycle" verdict onto the cycle row for THIS exploration task, so the
|
||||
next cycle's LEARN context explains a proposed-0 cycle instead of
|
||||
rendering a bare "proposed 0, approved 0" (see ``_render_cycle``).
|
||||
|
||||
Unlike ``record_decision`` this never touches items_proposed/
|
||||
approved/rejected or ``decisions`` — no item was proposed. Does NOT
|
||||
close the row; that stays ``_maybe_close``'s job once the (already
|
||||
COMPLETED, by the time this runs) exploration task is observed
|
||||
terminal. A best-effort no-op when no cycle row matches — mirrors
|
||||
every ``record_decision`` producer's own best-effort wrapping.
|
||||
"""
|
||||
cycle = await self._cycle_for_exploration(program_key, exploration_task_id)
|
||||
if cycle is None:
|
||||
return
|
||||
cycle.nothing_to_propose_reason = reason
|
||||
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."""
|
||||
@@ -500,6 +521,8 @@ class BoardProgramEngine(BaseService):
|
||||
return "\n".join(self._render_cycle(c) for c in reversed(cycles))
|
||||
|
||||
def _render_cycle(self, cycle: BoardProgramCycleTable) -> str:
|
||||
if cycle.items_proposed == 0 and cycle.nothing_to_propose_reason:
|
||||
return f"proposed 0 — nothing to propose: {cycle.nothing_to_propose_reason}"
|
||||
line = f"proposed {cycle.items_proposed}, approved {cycle.items_approved}"
|
||||
rejected = [d for d in cycle.decisions if d.get("verdict") == "rejected"]
|
||||
reasons = "; ".join(
|
||||
|
||||
@@ -20,7 +20,7 @@ import tarfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import structlog
|
||||
@@ -45,6 +45,7 @@ if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.foundation.identity import Team
|
||||
from roboco.foundation.policy.board_programs import BoardProgram
|
||||
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -492,6 +493,15 @@ _FRICTION_FIXES_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = (
|
||||
)
|
||||
_FRICTION_FIXES_EVIDENCE_MAX_CHARS = 2000
|
||||
|
||||
# nothing_to_propose is registry-driven, not role-frozenset-gated like every
|
||||
# verb above — it resolves the caller's NAMED task, derives the program from
|
||||
# that task's own source, then requires the caller's role to equal THAT
|
||||
# program's declared explorer role
|
||||
# (roboco.foundation.policy.board_programs.PROGRAMS), so a program registered
|
||||
# later needs no edit here.
|
||||
_NOTHING_TO_PROPOSE_REASON_MIN_CHARS = 15
|
||||
_NOTHING_TO_PROPOSE_REASON_MAX_CHARS = 800
|
||||
|
||||
# Playbook curation RBAC: delivery roles DRAFT; only the Auditor CURATES.
|
||||
# The Auditor is deliberately NOT in this set — "auditor curates but does not
|
||||
# draft" is an enforced invariant (test_playbook_verbs.py). A Coroner
|
||||
@@ -4955,6 +4965,152 @@ class ContentActions:
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
async def _resolve_nothing_to_propose_task(
|
||||
self, agent_id: UUID, task_id: UUID
|
||||
) -> Envelope | tuple[Any, BoardProgram]:
|
||||
"""Resolve + validate ``task_id`` for ``nothing_to_propose``: exists,
|
||||
its ``source`` is a registered Board Program, it is assigned to the
|
||||
caller, it is non-terminal, and the caller's role matches that
|
||||
program's declared explorer role. Returns the first failing check's
|
||||
envelope, else ``(task, program)`` — split out of the verb itself
|
||||
purely to keep its own return count under the lint ceiling.
|
||||
"""
|
||||
from roboco.foundation.policy.board_programs import PROGRAMS
|
||||
|
||||
task = await self.task.get(task_id)
|
||||
if task is None:
|
||||
return Envelope.not_found(message=f"task {task_id} not found")
|
||||
program = next((p for p in PROGRAMS.values() if p.source == task.source), None)
|
||||
if program is None:
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"task {task_id} source {task.source!r} is not a registered "
|
||||
"Board Program"
|
||||
),
|
||||
remediate="this task is not a Board Program exploration cycle",
|
||||
context_briefing={},
|
||||
)
|
||||
if task.assigned_to != agent_id:
|
||||
return Envelope.not_authorized(
|
||||
message=f"task {task_id} is not assigned to you",
|
||||
remediate=(
|
||||
"nothing_to_propose only completes the caller's own "
|
||||
"exploration task — pass the task_id printed as "
|
||||
"'TASK: <id>' at the top of your prompt"
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
if task.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED):
|
||||
return Envelope.invalid_state(
|
||||
message=f"task {task_id} is already {task.status.value}",
|
||||
remediate="this exploration cycle is already closed; nothing to do",
|
||||
context_briefing={},
|
||||
)
|
||||
role = await self._caller_role(agent_id)
|
||||
if role != program.role:
|
||||
return Envelope.not_authorized(
|
||||
message=(
|
||||
f"role {role!r} cannot resolve {program.key!r}'s exploration "
|
||||
f"task; only {program.role!r} does"
|
||||
),
|
||||
remediate=(
|
||||
f"nothing_to_propose only completes a {program.role} "
|
||||
"explorer's own cycle"
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
return task, program
|
||||
|
||||
async def nothing_to_propose(
|
||||
self,
|
||||
*,
|
||||
agent_id: UUID,
|
||||
task_id: UUID,
|
||||
reason: str,
|
||||
) -> Envelope:
|
||||
"""The explicit "this cycle found nothing worth proposing" exit for
|
||||
ANY Board Program exploration task, named explicitly by ``task_id``.
|
||||
|
||||
Every ``propose_*`` verb requires at least one item (or a single
|
||||
substantive report), so an explorer that legitimately has nothing —
|
||||
Barfly found no worthwhile X conversations, Coroner has no autopsy
|
||||
subject worth a process change — had no way to complete its task; it
|
||||
correctly declined and called ``i_am_idle()``, leaving the task
|
||||
PENDING forever: a permanent, expensive respawn loop, and (worse)
|
||||
``BoardProgramEngine``'s one-open-cycle dedup wedges that whole
|
||||
program shut, since the ledger row never closes.
|
||||
|
||||
``task_id`` is REQUIRED, not inferred: one explorer role owns several
|
||||
independently-cadenced programs at once (e.g. head_marketing owns
|
||||
x_feature/periscope/mirror/megaphone/war_room/barfly), each assigning
|
||||
its own exploration task to the same agent — so several of one
|
||||
agent's exploration tasks can be open simultaneously by design, and
|
||||
guessing (e.g. "the oldest one") completes the WRONG cycle and
|
||||
stamps its LEARN reason onto the wrong ledger row. The exploration
|
||||
prompt always prints "TASK: <id>", so the caller has it in hand
|
||||
(mirrors ``curate_vault``'s explicit ``task_id`` convention). See
|
||||
``_resolve_nothing_to_propose_task`` for the resolution + validation
|
||||
(exists, registered program, assigned to caller, non-terminal, role
|
||||
matches) — registry-driven, not a hardcoded role set, so a program
|
||||
registered later needs no edit here.
|
||||
|
||||
Completes the task (mirrors ``propose_conversation_replies``'/
|
||||
``propose_postmortem``'s complete-at-propose pattern) and records the
|
||||
reason onto the LEARN ledger row so the next cycle's exploration
|
||||
prompt sees WHY, not just a bare "proposed 0, approved 0".
|
||||
"""
|
||||
if rej := self._reject_soup(
|
||||
reason,
|
||||
field="reason",
|
||||
min_chars=_NOTHING_TO_PROPOSE_REASON_MIN_CHARS,
|
||||
):
|
||||
return rej
|
||||
if len(reason) > _NOTHING_TO_PROPOSE_REASON_MAX_CHARS:
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"reason is {len(reason)} chars, over the "
|
||||
f"{_NOTHING_TO_PROPOSE_REASON_MAX_CHARS}-char cap"
|
||||
),
|
||||
remediate="shorten the reason",
|
||||
context_briefing={},
|
||||
)
|
||||
|
||||
resolved = await self._resolve_nothing_to_propose_task(agent_id, task_id)
|
||||
if isinstance(resolved, Envelope):
|
||||
return resolved
|
||||
task, program = resolved
|
||||
|
||||
reason = reason.strip()
|
||||
task.status = TaskStatus.COMPLETED
|
||||
await self.task.session.flush()
|
||||
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
try:
|
||||
# Isolated in its own savepoint: record_nothing_to_propose does
|
||||
# its own flush() on this SAME session, and a bare try/except
|
||||
# around a same-session flush is not enough — a genuine DB
|
||||
# failure there leaves the session pending-rollback, so the
|
||||
# completion flushed just above would be silently discarded at
|
||||
# the outer commit despite this except swallowing the error.
|
||||
async with self.task.session.begin_nested():
|
||||
await get_board_program_engine(
|
||||
self.task.session
|
||||
).record_nothing_to_propose(program.key, cast("UUID", task.id), reason)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"nothing_to_propose: LEARN record failed (best-effort)",
|
||||
program=program.key,
|
||||
task_id=str(task.id),
|
||||
)
|
||||
|
||||
return Envelope.ok(
|
||||
status="nothing_to_propose",
|
||||
task_id=str(task.id),
|
||||
next="i_am_idle()",
|
||||
context_briefing={"program": program.key, "reason": reason},
|
||||
)
|
||||
|
||||
async def dm(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -121,6 +121,12 @@ _BOARD_DO = (
|
||||
"dm",
|
||||
"notify",
|
||||
"evidence",
|
||||
# Registry-driven "this cycle found nothing worth proposing" exit for
|
||||
# any Board Program exploration task — every board explorer role gets
|
||||
# it (Product Owner / Head of Marketing here; the Auditor separately
|
||||
# below), the runtime role-vs-program check in content_actions.py does
|
||||
# the real gating.
|
||||
"nothing_to_propose",
|
||||
*_NOTIFY_RECEIVER,
|
||||
)
|
||||
|
||||
@@ -187,6 +193,9 @@ _AUDITOR_DO = (
|
||||
"archive_playbook",
|
||||
"curate_vault",
|
||||
"propose_postmortem",
|
||||
# See _BOARD_DO's comment — the Auditor owns Coroner/Sentinel/Librarian,
|
||||
# so it needs the same "nothing to propose" exit.
|
||||
"nothing_to_propose",
|
||||
"notify_list",
|
||||
"notify_get",
|
||||
# Sentinel (Board Program): the Auditor's weekly org-wide drift report,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user