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
+8
View File
@@ -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/**'
+3 -2
View File
@@ -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/**'
+3 -2
View File
@@ -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'
+5
View File
@@ -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/**'
+5
View File
@@ -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'
+1
View File
@@ -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)` |
+3
View File
@@ -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")
+21
View File
@@ -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)
+11
View File
@@ -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)
+4
View File
@@ -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)
# =============================================================================
+24
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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(
+157 -1
View File
@@ -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,
*,
+9
View File
@@ -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."""