diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0559df0f..b10641f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/**' diff --git a/.github/workflows/code-ql.yml b/.github/workflows/code-ql.yml index bbd1b755..fc7b26ce 100644 --- a/.github/workflows/code-ql.yml +++ b/.github/workflows/code-ql.yml @@ -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/**' diff --git a/.github/workflows/codeql-js-ts.yml b/.github/workflows/codeql-js-ts.yml index 1c5ca012..afe9463b 100644 --- a/.github/workflows/codeql-js-ts.yml +++ b/.github/workflows/codeql-js-ts.yml @@ -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' diff --git a/.github/workflows/e2e-smoke.yml b/.github/workflows/e2e-smoke.yml index 123ca3d7..fc0d14d8 100644 --- a/.github/workflows/e2e-smoke.yml +++ b/.github/workflows/e2e-smoke.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/**' diff --git a/.github/workflows/panel-ci.yml b/.github/workflows/panel-ci.yml index ee154e02..ffef4758 100644 --- a/.github/workflows/panel-ci.yml +++ b/.github/workflows/panel-ci.yml @@ -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' diff --git a/agents/prompts/_generated/auditor.md b/agents/prompts/_generated/auditor.md index 6cc9c68e..2fdc2699 100644 --- a/agents/prompts/_generated/auditor.md +++ b/agents/prompts/_generated/auditor.md @@ -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)` | diff --git a/agents/prompts/_generated/head_marketing.md b/agents/prompts/_generated/head_marketing.md index e4d283f7..85aa1268 100644 --- a/agents/prompts/_generated/head_marketing.md +++ b/agents/prompts/_generated/head_marketing.md @@ -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)` | diff --git a/agents/prompts/_generated/product_owner.md b/agents/prompts/_generated/product_owner.md index 4cfaae70..bc0f086e 100644 --- a/agents/prompts/_generated/product_owner.md +++ b/agents/prompts/_generated/product_owner.md @@ -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)` | diff --git a/agents/prompts/_generated/verbs.md b/agents/prompts/_generated/verbs.md index fa2e973a..c3870cb3 100644 --- a/agents/prompts/_generated/verbs.md +++ b/agents/prompts/_generated/verbs.md @@ -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)` | diff --git a/alembic/versions/089_board_cycle_ntp_reason.py b/alembic/versions/089_board_cycle_ntp_reason.py new file mode 100644 index 00000000..b5c84c66 --- /dev/null +++ b/alembic/versions/089_board_cycle_ntp_reason.py @@ -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") diff --git a/roboco/api/routes/v1/do.py b/roboco/api/routes/v1/do.py index 8dd2bca7..f0051e33 100644 --- a/roboco/api/routes/v1/do.py +++ b/roboco/api/routes/v1/do.py @@ -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) diff --git a/roboco/api/schemas/v1/do.py b/roboco/api/schemas/v1/do.py index c6783c26..c6eb2660 100644 --- a/roboco/api/schemas/v1/do.py +++ b/roboco/api/schemas/v1/do.py @@ -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) diff --git a/roboco/db/tables.py b/roboco/db/tables.py index 7d909511..68ba2d4e 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -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) # ============================================================================= diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index b844db0a..7b1e46e3 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -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: " 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, diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 252c1a27..a62bbda9 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -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="") 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="") 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="") 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="") 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="") 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="") 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="") 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="") 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="") 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="") 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="") 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="") 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="") 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="", body="", rationale="") — 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="") 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="") 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 diff --git a/roboco/services/board_programs.py b/roboco/services/board_programs.py index 041a9e1d..72862073 100644 --- a/roboco/services/board_programs.py +++ b/roboco/services/board_programs.py @@ -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( diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index ae8eed55..ae472fdd 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -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: ' 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: ", 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, *, diff --git a/roboco/services/gateway/role_config.py b/roboco/services/gateway/role_config.py index 0716cfa4..d141dc74 100644 --- a/roboco/services/gateway/role_config.py +++ b/roboco/services/gateway/role_config.py @@ -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, diff --git a/tests/unit/gateway/test_content_actions_nothing_to_propose.py b/tests/unit/gateway/test_content_actions_nothing_to_propose.py new file mode 100644 index 00000000..3ec48943 --- /dev/null +++ b/tests/unit/gateway/test_content_actions_nothing_to_propose.py @@ -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 diff --git a/tests/unit/runtime/test_board_program_nothing_to_propose_prompts.py b/tests/unit/runtime/test_board_program_nothing_to_propose_prompts.py new file mode 100644 index 00000000..6a93f978 --- /dev/null +++ b/tests/unit/runtime/test_board_program_nothing_to_propose_prompts.py @@ -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) diff --git a/tests/unit/services/test_board_program_engine.py b/tests/unit/services/test_board_program_engine.py index cdfb31ab..373abc67 100644 --- a/tests/unit/services/test_board_program_engine.py +++ b/tests/unit/services/test_board_program_engine.py @@ -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."""