mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [a1f52d13] feat(gateway): re-apply evidence-assembly timeout fix on current main
Rebuilds the bounded-timeout fix for claim_review/evidence()/roboco_git_diff
that PR #765 carried as contaminated pre-Board-Programs file copies. The
rebase restored the current-main baseline; this commit re-applies ONLY the
validated timeout-fix hunks on top of that baseline:
- config.py: evidence_assembly_timeout_seconds (90.0s) and
conventions_validator_timeout_seconds (45s) Settings fields
- envelope.py: Envelope.gateway_timeout classmethod for structured
timeout errors naming the stalled component
- git.py: GitService.diff_and_files (single resolution, concurrent
diff+--name-only subprocesses), settings-backed conventions-validator
timeout, and changed_files passthrough to conventions_check_for_task
- qa.py: claim_review bounded-timeout wrapper around
_build_qa_claim_evidence, split into _qa_git_and_conventions and
_qa_db_reads concurrent segments (pass_review signature and gate
untouched)
- content_actions.py: evidence() bounded wrapper with _evidence_git_and_fetch
and _evidence_db_reads concurrent split (all other methods untouched)
- git routes: GET /diff bounded asyncio.wait_for guard with 504 on trip
The pre-existing methods (sync_env_branch, open_sync_pr, pass_review with
criteria_verified gate, all propose_*/request_render methods) are preserved
and untouched. The test files already exist on current main.
* [a1f52d13] fix(git-routes): wrap workspace resolution in bounded timeout for GET /diff
The bounded asyncio.wait_for must cover the workspace resolution step too,
not just the diff+stat subprocesses — the integration test makes
get_workspace slow (not _run_git), so the timeout guard needs to enclose
the entire resolve-and-diff path to return a structured 504 on trip.
* [a1f52d13] test: update mock setups from git.diff/list_changed_files to diff_and_files
Tests that exercise claim_review and evidence() paths need their git
mocks to return a tuple from diff_and_files instead of separate diff
and list_changed_files return values, matching the new combined accessor
introduced by the evidence-assembly timeout fix.
* [a1f52d13] test: configure diff_and_files mock in lifecycle parity test _make_deps
The spec-parity test test_claim_review_matches_spec[qa-awaiting_qa] failed
with ValueError at qa.py:330 because _make_deps set git=AsyncMock() without
configuring diff_and_files.return_value, so the await resolved to a MagicMock
instead of a (diff_summary, files_changed) 2-tuple. Adds the same
return_value = ("", []) configuration that commit 014a0d03 applied to the
other 4 test files.
* [a1f52d13] docs(evidence-assembly-timeout): correct GET /diff guard scope and _qa_git_and_conventions timing field
---------
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
6545 lines
269 KiB
Python
6545 lines
269 KiB
Python
"""Smart-wrapped content tools — commit, note, dm, read_a2a, evidence.
|
|
|
|
Each method:
|
|
1. Validates input (e.g., commit_validator for commit messages).
|
|
2. Auto-injects task_id when the agent has an active claim and the param is missing.
|
|
3. Calls the underlying service.
|
|
4. Returns a standardized Envelope.
|
|
|
|
Pure orchestration; no DB writes outside what the underlying services do.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import io
|
|
import re
|
|
import shutil
|
|
import tarfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
|
from urllib.parse import urlparse
|
|
|
|
import structlog
|
|
|
|
from roboco.config import settings
|
|
from roboco.exceptions import GitError
|
|
from roboco.foundation.policy import communications as _comms
|
|
from roboco.foundation.policy.content import ContentValidationError, markers
|
|
from roboco.foundation.policy.content.validators import reject_trivial
|
|
from roboco.foundation.policy.injection_guard import screen_external_text
|
|
from roboco.foundation.policy.journaling import Scope as _Scope
|
|
from roboco.models.base import TaskStatus
|
|
from roboco.services.content_notes import content_type_for_role
|
|
from roboco.services.gateway.choreographer import findings as findings_lib
|
|
from roboco.services.gateway.commit_validator import validate_commit_message
|
|
from roboco.services.gateway.envelope import Envelope
|
|
from roboco.services.gateway.evidence_builder import build_evidence_for_task
|
|
from roboco.services.x_client import MAX_TWEET_CHARS
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from uuid import UUID
|
|
|
|
from roboco.foundation.identity import Team
|
|
from roboco.foundation.policy.board_programs import BoardProgram
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
def _merge_resumption_fields(
|
|
section: dict[str, Any] | None,
|
|
*,
|
|
done: str,
|
|
next: str,
|
|
where_to_look: list[str] | None,
|
|
) -> dict[str, Any] | None:
|
|
"""Fold the top-level resumption fields into the handoff ``section``.
|
|
|
|
``section: dict[str, Any]`` renders a tool schema with no visible
|
|
sub-fields, so a weak model (minimax-m3) emits ``section={}`` and the
|
|
resumption gate rejects ``done — Field required`` (the 2026-06-27 PM
|
|
respawn-loop meltdown). The top-level ``done`` / ``next`` /
|
|
``where_to_look`` string fields are the LLM-facing contract — they show
|
|
up in the tool schema as discrete fields the same model fills fine. Here
|
|
they fill any keys the explicit ``section`` omits without overwriting
|
|
keys the agent already supplied, so a capable model passing ``section``
|
|
directly is unaffected. Returns ``None`` when nothing was supplied so the
|
|
downstream ``{'summary': text}`` fallback + gate remediation still fire.
|
|
"""
|
|
merged: dict[str, Any] = dict(section) if section else {}
|
|
if done and "done" not in merged:
|
|
merged["done"] = done
|
|
if next and "next" not in merged:
|
|
merged["next"] = next
|
|
if where_to_look and "where_to_look" not in merged:
|
|
merged["where_to_look"] = where_to_look
|
|
return merged or None
|
|
|
|
|
|
# Scope catalog is canonical in foundation.policy.journaling.
|
|
# Derived here as a string frozenset for the existing call sites that
|
|
# compare strings rather than the Scope enum.
|
|
_VALID_NOTE_SCOPES: frozenset[str] = frozenset(s.value for s in _Scope)
|
|
_TASK_ID_PREFIX_RE = re.compile(r"^\s*\[[a-zA-Z0-9_-]+\]\s*")
|
|
|
|
# Content-tool RBAC. These are the same role sets that drive the spawn
|
|
# manifest in `role_config.py` (`_DEV_DO`/`_DOC_DO` include "commit";
|
|
# `_CELL_PM_DO`/`_MAIN_PM_DO`/`_BOARD_DO` include "notify"). Pre-2026-05-10
|
|
# this lookup went through `verb_gates.is_verb_allowed`; the verb-gates
|
|
# table has been folded into `roboco.foundation.policy.lifecycle`, but
|
|
# `commit` and `notify` are content tools (not lifecycle intents) so they
|
|
# live here as explicit role frozensets — not in `_INTENT_VERBS`.
|
|
#
|
|
# Notification sender + priority allowlists are canonical in
|
|
# foundation.policy.communications. Derived as string frozensets here so
|
|
# the existing call sites that compare strings keep working.
|
|
_COMMIT_ALLOWED_ROLES: frozenset[str] = frozenset({"developer", "documenter"})
|
|
_NOTIFY_ALLOWED_ROLES: frozenset[str] = frozenset(
|
|
r.value for r in _comms.NOTIFY_SENDER_ROLES
|
|
)
|
|
|
|
# Roles with NO agent-comms surface (CLAUDE.md): the human-only prompter and
|
|
# secretary — restricted to note + evidence, no dm/notify, they own their own
|
|
# dedicated chat pages instead. (Auditor and pr_reviewer carry dm/read_a2a
|
|
# now — the CEO can DM either and it can reply in-thread — so they're no
|
|
# longer in this set; the auditor's silence toward PEERS is enforced
|
|
# separately in agents_config.can_a2a_direct.)
|
|
# The spawn manifest already omits dm from these roles' tool surfaces, but
|
|
# that is convention-only — this frozenset is the handler-level defence-in-depth
|
|
# that refuses any call that bypassed the manifest (direct verb dispatch, test
|
|
# harness, future routing change), so the no-comms invariant holds regardless of
|
|
# how the call arrived. Matches the explicit role-frozenset gates on commit /
|
|
# notify / pitch / playbook. Derived from the canonical set in
|
|
# foundation.policy.communications — agents_config.can_a2a_direct's CEO
|
|
# target-side check reuses the same source.
|
|
_NO_COMMS_ROLES: frozenset[str] = frozenset(r.value for r in _comms.NO_COMMS_ROLES)
|
|
|
|
|
|
_DECISION_SECTIONS: tuple[tuple[str, str], ...] = (
|
|
("context", "Context"),
|
|
("options", "Options Considered"),
|
|
("chosen", "Chosen"),
|
|
("rationale", "Rationale"),
|
|
("consequences", "Consequences"),
|
|
)
|
|
|
|
_REFLECT_SECTIONS: tuple[tuple[str, str], ...] = (
|
|
("what_done", "What Done"),
|
|
("what_learned", "What Learned"),
|
|
("what_struggled", "What Struggled"),
|
|
("next_steps", "Next Steps"),
|
|
)
|
|
|
|
|
|
def _render_option_block(option: dict[str, str] | str) -> str:
|
|
"""Render one decision option. Accepts dict or legacy string."""
|
|
if isinstance(option, str):
|
|
return f"- {option}"
|
|
name = option.get("name", "").strip() or "(unnamed)"
|
|
pros = option.get("pros", "").strip()
|
|
cons = option.get("cons", "").strip()
|
|
block = [f"### {name}"]
|
|
if pros:
|
|
block.append(f"- Pros: {pros}")
|
|
if cons:
|
|
block.append(f"- Cons: {cons}")
|
|
return "\n".join(block)
|
|
|
|
|
|
_SCOPE_SECTIONS: dict[str, tuple[tuple[str, str], ...]] = {
|
|
"decision": _DECISION_SECTIONS,
|
|
"reflect": _REFLECT_SECTIONS,
|
|
}
|
|
|
|
|
|
def _render_section_value(key: str, value: Any) -> str | None:
|
|
"""Render one section value or return None to skip the section."""
|
|
if key == "options" and isinstance(value, list):
|
|
if not value:
|
|
return None
|
|
return "\n\n".join(_render_option_block(o) for o in value)
|
|
if isinstance(value, list):
|
|
if not value:
|
|
return None
|
|
return "\n".join(f"- {item}" for item in value)
|
|
rendered = str(value).strip()
|
|
return rendered or None
|
|
|
|
|
|
def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -> str:
|
|
"""Build the journal entry body. Pre-gateway parity for decision/reflect."""
|
|
sections = _SCOPE_SECTIONS.get(scope, ())
|
|
if not sections:
|
|
return text
|
|
body_parts: list[str] = [text.strip()] if text.strip() else []
|
|
for key, label in sections:
|
|
value = structured.get(key)
|
|
if value is None:
|
|
continue
|
|
rendered = _render_section_value(key, value)
|
|
if rendered is None:
|
|
continue
|
|
body_parts.append(f"## {label}\n{rendered}")
|
|
return "\n\n".join(body_parts) if body_parts else text
|
|
|
|
|
|
# Narrative fields that decision/reflect scopes want filled. A
|
|
# missing or empty value used to hard-reject the note with `incomplete_input`
|
|
# — and since that kind counts toward the do-server circuit breaker, three
|
|
# well-intentioned-but-thin notes in a row tripped it. We now default the
|
|
# field instead so the note is always recorded (audit value preserved) and
|
|
# the breaker never fires on a note. The placeholder makes the gap visible in
|
|
# the panel rather than silently dropping the section.
|
|
_NARRATIVE_PLACEHOLDER = "(not provided)"
|
|
|
|
_DECISION_NARRATIVE_FIELDS: tuple[str, ...] = ("context", "chosen", "rationale")
|
|
_REFLECT_NARRATIVE_FIELDS: tuple[str, ...] = (
|
|
"what_done",
|
|
"what_learned",
|
|
"what_struggled",
|
|
)
|
|
|
|
# List-typed structured fields. A lone scalar is wrapped into a one-element
|
|
# list here too (defense-in-depth — the route schema coerces first, but the
|
|
# service is called directly from the choreographer and tests).
|
|
_LIST_FIELDS: tuple[str, ...] = ("options", "consequences", "next_steps")
|
|
|
|
_SCOPE_NARRATIVE_FIELDS: dict[str, tuple[str, ...]] = {
|
|
"decision": _DECISION_NARRATIVE_FIELDS,
|
|
"reflect": _REFLECT_NARRATIVE_FIELDS,
|
|
}
|
|
|
|
|
|
def _scalar_field_missing(value: Any) -> bool:
|
|
"""True when a required scalar/text field is empty/whitespace."""
|
|
return not value or not str(value).strip()
|
|
|
|
|
|
def _coerce_scalar_to_list(value: Any) -> Any:
|
|
"""Wrap a lone str/dict into a one-element list; pass lists/None through."""
|
|
if value is None or isinstance(value, list):
|
|
return value
|
|
if isinstance(value, str | dict):
|
|
return [value]
|
|
return value
|
|
|
|
|
|
def _normalize_structured(scope: str, structured: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a tolerant copy of ``structured`` for decision/reflect scopes.
|
|
|
|
Stop rejecting thin notes. List-typed fields tolerate a lone
|
|
scalar (wrapped into a one-element list), and missing/blank narrative
|
|
fields are defaulted to a visible placeholder so the entry still records
|
|
instead of returning `incomplete_input` (which trips the circuit breaker).
|
|
Other scopes are returned unchanged.
|
|
"""
|
|
normalized = dict(structured)
|
|
for field in _LIST_FIELDS:
|
|
if field in normalized:
|
|
normalized[field] = _coerce_scalar_to_list(normalized[field])
|
|
for field in _SCOPE_NARRATIVE_FIELDS.get(scope, ()):
|
|
if _scalar_field_missing(normalized.get(field)):
|
|
normalized[field] = _NARRATIVE_PLACEHOLDER
|
|
return normalized
|
|
|
|
|
|
def _ownership_violation(task_id: UUID) -> Envelope:
|
|
"""Standard envelope for Gate Set D ownership violations.
|
|
|
|
Pre-gateway, agents could not even see tasks they didn't own; the
|
|
gateway exposes task_id parameters so the explicit gate is required.
|
|
"""
|
|
return Envelope.not_authorized(
|
|
message=(f"you are not the assignee of {task_id}; cannot post content to it"),
|
|
remediate=(
|
|
"only the task's assignee may attach content (commit/note/"
|
|
"dm/evidence) to it. Use a different task_id or omit task_id "
|
|
"for off-task messages (dm only)."
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
|
|
def _not_active_claimant(task_id: UUID) -> Envelope:
|
|
"""Envelope for a caller who holds no active claim on ``task_id``.
|
|
|
|
The caller may still be the historical ``assigned_to`` (e.g. its claim
|
|
was reaped for going silent, or the task was handed to another agent),
|
|
but ``active_claimant_id`` no longer points at it. Writing would race the
|
|
real claimant, so the write is refused.
|
|
"""
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"you do not hold the active claim on {task_id}; "
|
|
"another agent owns it now or your claim was released"
|
|
),
|
|
remediate=(
|
|
"call i_am_idle() and give_me_work() to pick up fresh work; "
|
|
"if you believe this is your task, re-claim it before writing"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ContentActionsDeps:
|
|
"""Service deps for ContentActions; bundled to keep init signature flat."""
|
|
|
|
task: Any
|
|
git: Any
|
|
a2a: Any
|
|
journal: Any
|
|
workspace: Any
|
|
notifications: Any
|
|
# Wave 1 added inbox-read verbs (notify_list/get/ack) that live on
|
|
# `NotificationDeliveryService`, not `NotificationService`. Keeping
|
|
# them separate so the sender vs receiver concerns stay split.
|
|
notification_delivery: Any = None
|
|
# evidence() returns journal_highlights for QA/reviewer
|
|
# context. Matches the choreographer's EvidenceRepo wiring so both
|
|
# paths surface the same shape.
|
|
evidence_repo: Any = None
|
|
# request_sandbox's ensure_sandbox call. Mirrors ChoreographerDeps.orchestrator:
|
|
# optional so tests that don't exercise sandbox provisioning need not plumb
|
|
# it in; None degrades to a retryable "orchestrator unavailable" envelope.
|
|
orchestrator: Any = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _RenderSource:
|
|
"""A request_render source: a directory containing ``motion/``, plus its
|
|
provenance. ``cleanup`` (set only for a QA branch-export scratch dir) is
|
|
always called by request_render's ``finally``, dev or QA alike."""
|
|
|
|
root: Path
|
|
head_sha: str | None
|
|
dirty: bool
|
|
kind: str # "workspace" (dev's own tree) | "branch" (QA's read-only export)
|
|
cleanup: Callable[[], None] | None = None
|
|
|
|
|
|
_VALID_NOTIFY_PRIORITIES: frozenset[str] = frozenset(p.value for p in _comms.Priority)
|
|
# The Board roles that may author a pitch (a product proposal for CEO approval).
|
|
_PITCH_ROLES: frozenset[str] = frozenset({"product_owner", "head_marketing"})
|
|
|
|
# Roadmap cycles are PO-authored in v1 — HoM stays a reviewer via the normal
|
|
# board gate when an approved item later ships as real work (see the roadmap
|
|
# spec's non-goals).
|
|
_ROADMAP_ROLES: frozenset[str] = frozenset({"product_owner"})
|
|
|
|
# Pest Control (Board Program) bug hunts are Product-Owner-only, mirroring
|
|
# _ROADMAP_ROLES.
|
|
_PEST_ROLES: frozenset[str] = frozenset({"product_owner"})
|
|
|
|
# Spackle (Board Program) gap-fill audits are Product-Owner-only, mirroring
|
|
# _PEST_ROLES.
|
|
_GAP_FILL_ROLES: frozenset[str] = frozenset({"product_owner"})
|
|
|
|
# Mirror (Board Program) messaging-fix audits are Head-of-Marketing-only —
|
|
# the mirror image of _GAP_FILL_ROLES.
|
|
_MESSAGING_FIXES_ROLES: frozenset[str] = frozenset({"head_marketing"})
|
|
|
|
# Feature spotlights are HoM-authored — the Product Owner stays out of this
|
|
# cycle (mirrors _ROADMAP_ROLES's PO-only symmetry, reversed).
|
|
_FEATURE_SPOTLIGHT_ROLES: frozenset[str] = frozenset({"head_marketing"})
|
|
|
|
# Periscope market briefs are HoM-authored, mirroring _FEATURE_SPOTLIGHT_ROLES.
|
|
_PERISCOPE_ROLES: frozenset[str] = frozenset({"head_marketing"})
|
|
|
|
# Megaphone editorial posts are HoM-authored, mirroring _PERISCOPE_ROLES.
|
|
_MEGAPHONE_ROLES: frozenset[str] = frozenset({"head_marketing"})
|
|
_EDITORIAL_ANGLES: frozenset[str] = frozenset(
|
|
{"dev_log", "behind_scenes", "changelog_highlight", "other"}
|
|
)
|
|
_EDITORIAL_RATIONALE_MAX_CHARS = 300
|
|
|
|
# Barfly (Board Program) conversation replies are HoM-authored, mirroring
|
|
# _PERISCOPE_ROLES.
|
|
_BARFLY_ROLES: frozenset[str] = frozenset({"head_marketing"})
|
|
_BARFLY_RATIONALE_MAX_CHARS = 300
|
|
|
|
# Market-brief free-text caps (spec §4 / Task 2). ``source_url`` is validated
|
|
# separately (a URL, not soup-checked prose) — see _reject_market_brief_url.
|
|
_MARKET_BRIEF_HEADLINE_MAX_CHARS = 200
|
|
_MARKET_BRIEF_FINDING_CLAIM_MAX_CHARS = 500
|
|
_MARKET_BRIEF_FINDING_SOURCE_URL_MAX_CHARS = 300
|
|
_MARKET_BRIEF_FINDING_RELEVANCE_MAX_CHARS = 300
|
|
_MARKET_BRIEF_POSITIONING_NOTE_MAX_CHARS = 500
|
|
_MARKET_BRIEF_LIST_MAX_ITEMS = 5 # threats / opportunities cap
|
|
_MARKET_BRIEF_LIST_ITEM_MAX_CHARS = 300
|
|
|
|
# Coroner postmortems are Auditor-authored — the one program the Auditor
|
|
# originates content for (spec §4), distinct from its curation-only
|
|
# approve/reject_playbook verbs.
|
|
_CORONER_ROLES: frozenset[str] = frozenset({"auditor"})
|
|
_CORONER_PROCESS_CHANGE_KINDS: frozenset[str] = frozenset(
|
|
{"playbook", "prompt_fix", "conventions_rule", "other"}
|
|
)
|
|
_CORONER_INCIDENT_SUMMARY_MAX_CHARS = 500
|
|
_CORONER_ROOT_CAUSE_MAX_CHARS = 800
|
|
_CORONER_PROCESS_CHANGE_DESC_MAX_CHARS = 800
|
|
|
|
# Sentinel (Board Program) quality reports are Auditor-authored — a bounded
|
|
# expansion mirroring _PEST_ROLES/_PERISCOPE_ROLES.
|
|
_SENTINEL_ROLES: frozenset[str] = frozenset({"auditor"})
|
|
|
|
# Quality-report free-text caps (spec §4).
|
|
_QUALITY_REPORT_HEADLINE_MAX_CHARS = 200
|
|
_QUALITY_REPORT_ITEM_OBSERVATION_MAX_CHARS = 500
|
|
_QUALITY_REPORT_ITEM_EVIDENCE_MAX_CHARS = 500
|
|
_QUALITY_REPORT_ITEM_SUGGESTED_ACTION_MAX_CHARS = 300
|
|
_QUALITY_REPORT_OVERALL_ASSESSMENT_MAX_CHARS = 800
|
|
_QUALITY_REPORT_AREAS: frozenset[str] = frozenset(
|
|
{"waivers", "findings", "conventions", "budget", "docs", "other"}
|
|
)
|
|
|
|
# Librarian (Board Program) playbook drafts are Auditor-authored, mirroring
|
|
# _SENTINEL_ROLES. Each draft is created directly via PlaybookService (the
|
|
# Coroner _draft_coroner_playbook precedent) — the Auditor does NOT also gain
|
|
# draft_playbook (see _DRAFT_PLAYBOOK_ROLES above / role_config.py).
|
|
_LIBRARIAN_ROLES: frozenset[str] = frozenset({"auditor"})
|
|
_PLAYBOOK_DRAFT_TITLE_MAX_CHARS = 200 # matches PlaybookCreate.title's own cap
|
|
_PLAYBOOK_DRAFT_BODY_MAX_CHARS = 4000
|
|
_PLAYBOOK_DRAFT_PATTERN_EVIDENCE_MAX_CHARS = 500
|
|
|
|
# Text fields on a roadmap item draft, with their anti-soup minimum length.
|
|
_ROADMAP_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = (
|
|
("title", 5),
|
|
("description", 15),
|
|
("project_slug", 2),
|
|
("team", 2),
|
|
("rationale", 8),
|
|
)
|
|
|
|
# Text fields on a pest-hunt item draft. ``evidence`` is the load-bearing one
|
|
# (spec §4: "a bug hunt without evidence is noise") — required, substantive,
|
|
# and capped so a runaway dump can't blow out the marker payload.
|
|
_PEST_HUNT_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = (
|
|
("title", 5),
|
|
("description", 15),
|
|
("project_slug", 2),
|
|
("team", 2),
|
|
("evidence", 20),
|
|
)
|
|
_PEST_HUNT_EVIDENCE_MAX_CHARS = 2000
|
|
|
|
# Text fields on a gap-fill item draft. ``evidence`` is the load-bearing one
|
|
# (spec §4: evidence must name BOTH sides of the gap — e.g. the route that
|
|
# exists and the panel surface that doesn't) — required, substantive, and
|
|
# capped so a runaway dump can't blow out the marker payload. Mirrors
|
|
# _PEST_HUNT_ITEM_TEXT_FIELDS.
|
|
_GAP_FILL_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = (
|
|
("title", 5),
|
|
("description", 15),
|
|
("project_slug", 2),
|
|
("team", 2),
|
|
("evidence", 20),
|
|
)
|
|
_GAP_FILL_EVIDENCE_MAX_CHARS = 2000
|
|
|
|
# Scales (Board Program) rebalance plans are Product-Owner-only, mirroring
|
|
# _PEST_ROLES. Unlike a roadmap/pest-control item, a rebalance item never
|
|
# drafts a NEW task — it references a LIVE one (``task_ref``) that approval
|
|
# mutates (reprioritize) or cancels, so there is no team/project-slug/
|
|
# acceptance-criteria shape to validate here, just the action + rationale.
|
|
_SCALES_ROLES: frozenset[str] = frozenset({"product_owner"})
|
|
_SCALES_ACTIONS: frozenset[str] = frozenset({"reprioritize", "cancel"})
|
|
_SCALES_VALID_PRIORITIES: frozenset[int] = frozenset({0, 1, 2, 3})
|
|
_SCALES_RATIONALE_MAX_CHARS = 500
|
|
|
|
# Text fields on a messaging-fix item draft. ``evidence`` is the load-bearing
|
|
# one (spec §4: must name the drifted claim AND the reality it contradicts)
|
|
# — required, substantive, and capped so a runaway dump can't blow out the
|
|
# marker payload. Mirrors _GAP_FILL_ITEM_TEXT_FIELDS.
|
|
_MESSAGING_FIX_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = (
|
|
("title", 5),
|
|
("description", 15),
|
|
("project_slug", 2),
|
|
("team", 2),
|
|
("evidence", 20),
|
|
)
|
|
_MESSAGING_FIX_EVIDENCE_MAX_CHARS = 2000
|
|
|
|
# War Room (Board Program) campaigns are HoM-authored, mirroring
|
|
# _FEATURE_SPOTLIGHT_ROLES/_PERISCOPE_ROLES.
|
|
_WAR_ROOM_ROLES: frozenset[str] = frozenset({"head_marketing"})
|
|
_CAMPAIGN_STAGE_LABELS: frozenset[str] = frozenset(
|
|
{"teaser", "launch", "follow_up", "spotlight", "other"}
|
|
)
|
|
_CAMPAIGN_NAME_MAX_CHARS = 100
|
|
_CAMPAIGN_MIN_POSTS = 2
|
|
_CAMPAIGN_MAX_POSTS = 6
|
|
|
|
# Dogfood (Board Program) friction-fix audits are Product-Owner-only,
|
|
# mirroring _GAP_FILL_ROLES.
|
|
_DOGFOOD_ROLES: frozenset[str] = frozenset({"product_owner"})
|
|
|
|
# Text fields on a friction-fix item draft. ``evidence`` is the load-bearing
|
|
# one (spec §4: the actual walked path — clicks/pages — plus what broke or
|
|
# felt wrong, prose, no screenshots) — required, substantive, and capped so
|
|
# a runaway dump can't blow out the marker payload. Mirrors
|
|
# _GAP_FILL_ITEM_TEXT_FIELDS.
|
|
_FRICTION_FIXES_ITEM_TEXT_FIELDS: tuple[tuple[str, int], ...] = (
|
|
("title", 5),
|
|
("description", 15),
|
|
("project_slug", 2),
|
|
("team", 2),
|
|
("evidence", 20),
|
|
)
|
|
_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
|
|
# postmortem's playbook-kind process change is drafted through a DIFFERENT
|
|
# path — directly via PlaybookService inside propose_postmortem, never this
|
|
# do-verb (see _draft_coroner_playbook below) — so the invariant holds even
|
|
# though the Auditor now originates playbook drafts by another route; that
|
|
# draft rides the SAME pending-playbook curation queue every delivery-role
|
|
# draft does, curated by the Auditor same as any other, never self-approved
|
|
# inline.
|
|
_DRAFT_PLAYBOOK_ROLES: frozenset[str] = frozenset(
|
|
{"developer", "qa", "documenter", "cell_pm", "main_pm"}
|
|
)
|
|
_CURATE_PLAYBOOK_ROLES: frozenset[str] = frozenset({"auditor"})
|
|
|
|
# Vault-curation: only the Auditor writes a root task-tree's narrative
|
|
# (mirrors the playbook-curation bounded-expansion pattern above).
|
|
_CURATE_VAULT_ROLES: frozenset[str] = frozenset({"auditor"})
|
|
|
|
# propose_video's target-platform set + TikTok caption limit (the X caption
|
|
# reuses MAX_TWEET_CHARS). No role frozenset here, unlike the sets above:
|
|
# propose_video is gated on the caller's TEAM at runtime (_caller_team), not
|
|
# role — Role.DEVELOPER doesn't distinguish a ux-dev from a be-dev/fe-dev.
|
|
# Kept in lockstep with video-renderer/server.js COMPOSITION_ID_RE.
|
|
_COMPOSITION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$")
|
|
_VIDEO_PLATFORMS: frozenset[str] = frozenset({"x", "tiktok"})
|
|
_MAX_TIKTOK_CAPTION_CHARS = 2200
|
|
|
|
|
|
def _coerce_pitch_cells(target_cells: list[str]) -> list[Any]:
|
|
"""Validate target-cell slugs into Team values; raise ValueError on a bad one."""
|
|
from roboco.foundation.identity import CELL_TEAMS, Team
|
|
|
|
cells: list[Any] = []
|
|
for c in target_cells:
|
|
try:
|
|
team = Team(c)
|
|
except ValueError as exc:
|
|
raise ValueError(f"unknown cell {c!r}") from exc
|
|
if team not in CELL_TEAMS:
|
|
raise ValueError(f"{c!r} is not a cell team")
|
|
cells.append(team)
|
|
return cells
|
|
|
|
|
|
def _normalize_roadmap_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Coerce a validated raw item dict into the stored marker shape.
|
|
|
|
``id`` is server-assigned (index-based, stable within the cycle) — the PO
|
|
never sets it, so there's no collision/typo surface for the CEO's
|
|
per-item approve/reject to key on.
|
|
"""
|
|
priority = raw.get("priority")
|
|
try:
|
|
priority = int(priority) if priority is not None else 2
|
|
except (TypeError, ValueError):
|
|
priority = 2
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"title": str(raw["title"]).strip(),
|
|
"description": str(raw["description"]).strip(),
|
|
"acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]],
|
|
"project_slug": str(raw["project_slug"]).strip(),
|
|
"team": str(raw["team"]).strip(),
|
|
"priority": priority,
|
|
"rationale": str(raw["rationale"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
def _normalize_pest_hunt_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Coerce a validated raw pest-hunt item dict into the stored marker
|
|
shape. Mirrors ``_normalize_roadmap_item`` — ``id`` is server-assigned."""
|
|
priority = raw.get("priority")
|
|
try:
|
|
priority = int(priority) if priority is not None else 2
|
|
except (TypeError, ValueError):
|
|
priority = 2
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"title": str(raw["title"]).strip(),
|
|
"description": str(raw["description"]).strip(),
|
|
"acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]],
|
|
"project_slug": str(raw["project_slug"]).strip(),
|
|
"team": str(raw["team"]).strip(),
|
|
"priority": priority,
|
|
"evidence": str(raw["evidence"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
def _normalize_gap_fill_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Coerce a validated raw gap-fill item dict into the stored marker
|
|
shape. Mirrors ``_normalize_pest_hunt_item`` — ``id`` is server-assigned."""
|
|
priority = raw.get("priority")
|
|
try:
|
|
priority = int(priority) if priority is not None else 2
|
|
except (TypeError, ValueError):
|
|
priority = 2
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"title": str(raw["title"]).strip(),
|
|
"description": str(raw["description"]).strip(),
|
|
"acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]],
|
|
"project_slug": str(raw["project_slug"]).strip(),
|
|
"team": str(raw["team"]).strip(),
|
|
"priority": priority,
|
|
"evidence": str(raw["evidence"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
def _normalize_scales_item(
|
|
idx: int, raw: dict[str, Any], target: Any
|
|
) -> dict[str, Any]:
|
|
"""Coerce a validated raw rebalance item dict + its resolved target task
|
|
into the stored marker shape. Mirrors ``_normalize_pest_hunt_item`` —
|
|
``id`` is server-assigned. Unlike a pest-hunt item there is no draft to
|
|
normalize: ``target`` (resolved by ``TaskService.resolve_scales_task_ref``
|
|
before this is called) supplies the id/title actually acted on."""
|
|
action = str(raw["action"]).strip()
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"task_ref": str(raw["task_ref"]).strip(),
|
|
"target_task_id": str(target.id),
|
|
"target_task_title": target.title,
|
|
"action": action,
|
|
"new_priority": raw.get("new_priority") if action == "reprioritize" else None,
|
|
"rationale": str(raw["rationale"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"executed_detail": None,
|
|
}
|
|
|
|
|
|
def _normalize_messaging_fix_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Coerce a validated raw messaging-fix item dict into the stored marker
|
|
shape. Mirrors ``_normalize_gap_fill_item`` — ``id`` is server-assigned."""
|
|
priority = raw.get("priority")
|
|
try:
|
|
priority = int(priority) if priority is not None else 2
|
|
except (TypeError, ValueError):
|
|
priority = 2
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"title": str(raw["title"]).strip(),
|
|
"description": str(raw["description"]).strip(),
|
|
"acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]],
|
|
"project_slug": str(raw["project_slug"]).strip(),
|
|
"team": str(raw["team"]).strip(),
|
|
"priority": priority,
|
|
"evidence": str(raw["evidence"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
def _normalize_friction_fix_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Coerce a validated raw friction-fix item dict into the stored marker
|
|
shape. Mirrors ``_normalize_messaging_fix_item`` — ``id`` is server-
|
|
assigned."""
|
|
priority = raw.get("priority")
|
|
try:
|
|
priority = int(priority) if priority is not None else 2
|
|
except (TypeError, ValueError):
|
|
priority = 2
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"title": str(raw["title"]).strip(),
|
|
"description": str(raw["description"]).strip(),
|
|
"acceptance_criteria": [str(c).strip() for c in raw["acceptance_criteria"]],
|
|
"project_slug": str(raw["project_slug"]).strip(),
|
|
"team": str(raw["team"]).strip(),
|
|
"priority": priority,
|
|
"evidence": str(raw["evidence"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
def _normalize_market_brief_finding(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Coerce a validated raw market-brief finding into the stored marker
|
|
shape. Mirrors ``_normalize_pest_hunt_item`` — ``id`` is server-assigned.
|
|
|
|
``status``/``reject_reason``/``materialized_task_id`` mirror the roadmap/
|
|
pest-hunt item shape even though the exploration task itself completes
|
|
at propose time — the finding still carries its OWN per-item CEO
|
|
decision (``PeriscopeService.approve_finding``/``reject_finding``),
|
|
orthogonal to the task's own terminal status.
|
|
"""
|
|
return {
|
|
"id": f"finding-{idx}",
|
|
"claim": str(raw["claim"]).strip(),
|
|
"source_url": str(raw["source_url"]).strip(),
|
|
"relevance": str(raw["relevance"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
def _normalize_quality_report_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Coerce a validated raw quality-report item into the stored marker
|
|
shape. Mirrors ``_normalize_market_brief_finding`` — ``id`` is
|
|
server-assigned, and the same per-item ``status`` triple applies (see
|
|
``SentinelService.approve_item``/``reject_item``)."""
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"area": str(raw["area"]).strip(),
|
|
"observation": str(raw["observation"]).strip(),
|
|
"evidence": str(raw["evidence"]).strip(),
|
|
"suggested_action": str(raw["suggested_action"]).strip(),
|
|
"status": "proposed",
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
def _render_market_brief_for_screening(
|
|
headline: str,
|
|
findings: list[dict[str, Any]],
|
|
threats: list[str],
|
|
opportunities: list[str],
|
|
positioning_note: str,
|
|
) -> str:
|
|
"""One line per content piece — every line is independently checked by
|
|
``screen_external_text``, so a single injected line among otherwise-clean
|
|
web-derived content is flagged without dropping the rest of the brief."""
|
|
lines = [f"Headline: {headline}"]
|
|
for f in findings:
|
|
lines.append(f"Finding: {f['claim']} (source: {f['source_url']})")
|
|
lines.append(f"Relevance: {f['relevance']}")
|
|
lines.extend(f"Threat: {t}" for t in threats)
|
|
lines.extend(f"Opportunity: {o}" for o in opportunities)
|
|
if positioning_note:
|
|
lines.append(f"Positioning: {positioning_note}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
class ContentActions:
|
|
def __init__(self, deps: ContentActionsDeps) -> None:
|
|
self._deps = deps
|
|
|
|
@property
|
|
def task(self) -> Any:
|
|
return self._deps.task
|
|
|
|
@property
|
|
def git(self) -> Any:
|
|
return self._deps.git
|
|
|
|
@property
|
|
def a2a(self) -> Any:
|
|
return self._deps.a2a
|
|
|
|
@property
|
|
def journal(self) -> Any:
|
|
return self._deps.journal
|
|
|
|
@property
|
|
def workspace(self) -> Any:
|
|
return self._deps.workspace
|
|
|
|
@property
|
|
def notifications(self) -> Any:
|
|
return self._deps.notifications
|
|
|
|
@property
|
|
def evidence_repo(self) -> Any:
|
|
return self._deps.evidence_repo
|
|
|
|
@property
|
|
def orchestrator(self) -> Any:
|
|
return self._deps.orchestrator
|
|
|
|
async def _touch_heartbeat(self, task_id: UUID | None) -> None:
|
|
"""Best-effort heartbeat refresh on a content-write success path.
|
|
|
|
Mirrors the choreographer's rejection-path heartbeat: an agent that
|
|
is actively committing / posting progress is alive, so refresh
|
|
``last_heartbeat_at`` here too — otherwise the reaper sees the claim
|
|
as stale between verb successes. Wrapped in ``suppress`` so a
|
|
heartbeat write failure can never alter the response the agent gets.
|
|
"""
|
|
if task_id is None:
|
|
return
|
|
with contextlib.suppress(Exception):
|
|
await self.task.heartbeat(task_id)
|
|
|
|
async def _active_claim_violation(
|
|
self, agent_id: UUID, task: Any
|
|
) -> Envelope | None:
|
|
"""Refuse a content write when the caller is not the active claimant.
|
|
|
|
``assigned_to`` alone is insufficient: a reaped or handed-off agent
|
|
keeps ``assigned_to`` until reassignment, but ``active_claimant_id``
|
|
is cleared the moment its claim is released. Only the holder of the
|
|
active claim may write. A board co-reviewer on a coordination task is
|
|
exempt (it shares the task with the other board member by design).
|
|
"""
|
|
claimant = getattr(task, "active_claimant_id", None)
|
|
if claimant == agent_id:
|
|
return None
|
|
if await self._board_may_co_review(agent_id, task):
|
|
return None
|
|
return _not_active_claimant(task.id)
|
|
|
|
@staticmethod
|
|
def _reject_soup(value: str, *, field: str, min_chars: int = 3) -> Envelope | None:
|
|
"""Universal anti-soup guard for agent free-text.
|
|
|
|
Returns a remediation Envelope (never a raw 422 — a 422 at the route
|
|
trips the do-server circuit breaker) when ``value`` is empty, too short,
|
|
or a placeholder/filler token, so soup lands NOWHERE. ``None`` = clean.
|
|
"""
|
|
try:
|
|
reject_trivial(value, field=field, min_chars=min_chars)
|
|
except ValueError as exc:
|
|
return Envelope.invalid_state(
|
|
message=str(exc),
|
|
remediate=(
|
|
f"write a substantive {field} (>={min_chars} chars, no filler "
|
|
"like 'asdf'/'wip'/'tbd'/'...'); state what actually happened."
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_structured_soup(
|
|
cls, scope: str, structured: dict[str, Any] | None
|
|
) -> Envelope | None:
|
|
"""Soup-guard the scope's narrative sub-fields when the agent fills them.
|
|
|
|
Only *provided, non-empty* fields are checked — an omitted narrative
|
|
field keeps its tolerant ``(not provided)`` placeholder default (so a
|
|
thin note is never hard-rejected, preserving the do-server breaker
|
|
contract), but ``rationale='asdf'`` is soup and lands nowhere.
|
|
"""
|
|
for field in _SCOPE_NARRATIVE_FIELDS.get(scope, ()):
|
|
value = (structured or {}).get(field)
|
|
if not (value and str(value).strip()):
|
|
continue
|
|
if rej := cls._reject_soup(str(value), field=field, min_chars=4):
|
|
return rej
|
|
return None
|
|
|
|
@classmethod
|
|
def _pr_update_input_check(
|
|
cls, title: str | None, body: str | None, reviewers: list[str] | None
|
|
) -> Envelope | None:
|
|
"""At-least-one-field + anti-soup gate for ``pr_update`` inputs.
|
|
|
|
Folds the no-op guard and the title/body soup guard into one call so
|
|
the verb body keeps its return count under the complexity bound.
|
|
"""
|
|
if title is None and body is None and reviewers is None:
|
|
return Envelope.invalid_state(
|
|
message="no fields to update",
|
|
remediate=(
|
|
"provide at least one of title, body, or reviewers; "
|
|
"passing all None has no effect"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
for _pf, _pv, _min in (("title", title, 8), ("body", body, 15)):
|
|
if _pv is not None and (
|
|
rej := cls._reject_soup(_pv, field=_pf, min_chars=_min)
|
|
):
|
|
return rej
|
|
return None
|
|
|
|
async def commit(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
message: str,
|
|
files: list[str] | None = None,
|
|
) -> Envelope:
|
|
"""Make a git commit on the agent's active task branch.
|
|
|
|
Auto-prefixes [task-id], validates message via commit_validator,
|
|
records progress entry from the commit message.
|
|
"""
|
|
agent = await self.task.agent_for(agent_id)
|
|
caller_role = str(agent.role) if agent is not None else ""
|
|
if caller_role not in _COMMIT_ALLOWED_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role '{caller_role}' may not commit code; only"
|
|
" developers and documenters write commits"
|
|
),
|
|
remediate=(
|
|
"PMs delegate code work via delegate(); board members"
|
|
" do not write code. If you intended to record an"
|
|
" observation, use note() instead."
|
|
),
|
|
context_briefing={},
|
|
)
|
|
subject = _strip_task_prefix(_strip_ai_attribution(message)).strip()
|
|
result = validate_commit_message(
|
|
subject,
|
|
min_chars=settings.commit_subject_min_chars,
|
|
banned_words=settings.commit_banned_words,
|
|
)
|
|
if not result.ok:
|
|
return Envelope.invalid_state(
|
|
message=result.reason or "commit message invalid",
|
|
remediate=result.remediate or "",
|
|
context_briefing={},
|
|
)
|
|
t = await self.task.get_active_task_for_agent(agent_id)
|
|
if t is None:
|
|
return Envelope.invalid_state(
|
|
message="no active task; cannot commit",
|
|
remediate="call give_me_work() first",
|
|
context_briefing={},
|
|
)
|
|
if reject := await self._active_claim_violation(agent_id, t):
|
|
return reject
|
|
canonical_prefix = f"[{str(t.id)[:8]}]"
|
|
final_message = f"{canonical_prefix} {subject}"
|
|
try:
|
|
commit_result = await self.git.commit(
|
|
branch_name=t.branch_name,
|
|
message=final_message,
|
|
task_id=t.id,
|
|
files=files,
|
|
)
|
|
except GitError as exc:
|
|
return self._commit_git_error_envelope(exc, files=files)
|
|
sha = commit_result.get("sha", "")
|
|
await self.task.add_progress(
|
|
t.id, agent_id, f"committed {sha[:8]}: {final_message}"
|
|
)
|
|
await self._touch_heartbeat(t.id)
|
|
return Envelope.ok(
|
|
status=str(t.status),
|
|
task_id=str(t.id),
|
|
next="continue committing, or open_pr when ready",
|
|
context_briefing={},
|
|
)
|
|
|
|
@staticmethod
|
|
def _commit_git_error_envelope(
|
|
exc: GitError, *, files: list[str] | None
|
|
) -> Envelope:
|
|
"""Map a failed `git commit` onto an actionable invalid_state envelope.
|
|
|
|
A "no changes added to commit" / "nothing to commit" failure means
|
|
the passed `files` matched no modified paths; anything else is a
|
|
generic git failure the agent should inspect and retry.
|
|
"""
|
|
text = str(exc)
|
|
if files and (
|
|
"no changes added to commit" in text or "nothing to commit" in text
|
|
):
|
|
remediate = (
|
|
f"the files list {files!r} matched no modified paths; omit "
|
|
"files to stage all changes, or pass the exact modified paths"
|
|
)
|
|
else:
|
|
remediate = "inspect the git error above and retry"
|
|
return Envelope.invalid_state(
|
|
message=text,
|
|
remediate=remediate,
|
|
context_briefing={},
|
|
)
|
|
|
|
# Board roles co-review board/coordination tasks: a
|
|
# board/coordination task is dispatched to BOTH the Product Owner and the
|
|
# Head of Marketing, but it carries a single ``assigned_to``. The
|
|
# non-assignee reviewer must still be able to record its review note on
|
|
# that task, so a board role posting content to a board/coordination task
|
|
# is exempt from the strict single-assignee ownership gate.
|
|
_BOARD_ROLES: ClassVar[frozenset[str]] = frozenset(
|
|
{"product_owner", "head_marketing"}
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_coordination_task(task: Any) -> bool:
|
|
"""True for a board/fan-out task: carries a product but no repo of its own.
|
|
|
|
Mirrors the orchestrator's ``_is_coordination_task``. Such a task does
|
|
no git work of its own (no ``project_id``) and is the shared subject of
|
|
the two-reviewer board review, so both board members may attach content.
|
|
"""
|
|
return getattr(task, "project_id", None) is None and bool(
|
|
getattr(task, "product_id", None)
|
|
)
|
|
|
|
async def _board_may_co_review(self, agent_id: UUID, task: Any) -> bool:
|
|
"""True iff a board role is posting to a board/coordination task.
|
|
|
|
Lets the non-assignee board reviewer record its review on a task held
|
|
by the other board member, without widening ownership for any other
|
|
role or any project-backed task.
|
|
"""
|
|
if not self._is_coordination_task(task):
|
|
return False
|
|
agent = await self.task.agent_for(agent_id)
|
|
role = str(agent.role) if agent is not None else ""
|
|
return role in self._BOARD_ROLES
|
|
|
|
async def _verify_explicit_task_ownership(
|
|
self, agent_id: UUID, task_id: UUID
|
|
) -> Envelope | None:
|
|
"""Gate Set D: refuse content posts on tasks the caller does not own.
|
|
|
|
Only call this for *explicit* task_id (caller passed it themselves).
|
|
Auto-fill from get_journal_context_task_for_agent is implicitly
|
|
self-owned and does not need a re-check.
|
|
|
|
Allows ``assigned_to=None`` (post-handoff transient state) so QA /
|
|
documenter can still inspect tasks between reassignments. A board role
|
|
co-reviewing a board/coordination task is also allowed even when the
|
|
task is assigned to the other board member.
|
|
"""
|
|
t = await self.task.get(task_id)
|
|
if t is None:
|
|
return Envelope.not_found(message=f"task {task_id} not found")
|
|
if t.assigned_to is not None and t.assigned_to != agent_id:
|
|
if await self._board_may_co_review(agent_id, t):
|
|
return None
|
|
return _ownership_violation(task_id)
|
|
# assigned_to is stale across a reap/handoff (persists until
|
|
# reassignment; active_claimant_id is cleared on release). Require
|
|
# the active claim so a reaped agent can't keep posting.
|
|
if t.assigned_to == agent_id:
|
|
return await self._active_claim_violation(agent_id, t)
|
|
return None
|
|
|
|
async def note(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
text: str,
|
|
scope: str = "note",
|
|
task_id: UUID | None = None,
|
|
structured: dict[str, Any] | None = None,
|
|
section: dict[str, Any] | None = None,
|
|
done: str = "",
|
|
next: str = "",
|
|
where_to_look: list[str] | None = None,
|
|
) -> Envelope:
|
|
"""Write a journal entry, or (scope='handoff') the role's note section.
|
|
|
|
scope ∈ note|decision|reflect|learning|struggle write the JOURNAL;
|
|
scope='handoff' writes the agent's dedicated SECTION (dev_notes /
|
|
quick_context / auditor_notes …) from ``section`` (or a summary from
|
|
``text`` when ``section`` is omitted).
|
|
|
|
``structured`` carries scope-specific fields:
|
|
|
|
- decision: context, options[], chosen, rationale, consequences
|
|
- reflect: what_done, what_learned, what_struggled, next_steps
|
|
|
|
For ``scope='handoff'`` the resumption section (PM / coordinator
|
|
roles) can be authored two ways: the nested ``section`` dict, OR the
|
|
top-level ``done`` / ``next`` / ``where_to_look`` string fields. The
|
|
top-level path is the LLM-facing contract — ``section: dict[str, Any]``
|
|
renders a tool schema with no visible sub-fields, so a weak model
|
|
(minimax-m3) emits ``section={}`` and the resumption gate rejects
|
|
``done — Field required``; the top-level typed strings show up in the
|
|
tool schema as discrete fields the same model fills fine (proven on
|
|
the decision scope). Top-level fields fill any keys the explicit
|
|
``section`` omits without overwriting supplied ones.
|
|
|
|
The note is always recorded. List-typed fields tolerate a
|
|
lone scalar (wrapped into a one-element list) and missing decision/
|
|
reflect narrative fields default to a visible placeholder, so a
|
|
well-intentioned note is never hard-rejected (which previously tripped
|
|
the do-server circuit breaker on repeated ``incomplete_input``).
|
|
|
|
Non-None fields are formatted into the entry content as markdown
|
|
sections so the panel's Decisions / Reflections views render
|
|
them as named blocks instead of a one-line phrase. The ``title``
|
|
is taken from ``structured["title"]`` when present, otherwise
|
|
from the first line of ``text``.
|
|
"""
|
|
if scope == "handoff":
|
|
# Section write (dev_notes / quick_context / auditor_notes / …), not
|
|
# a journal entry. Content quality is enforced by the content model
|
|
# (apply_structured_note), so skip the journal-text soup check.
|
|
return await self._record_section_handoff(
|
|
agent_id=agent_id,
|
|
text=text,
|
|
task_id=task_id,
|
|
structured=_merge_resumption_fields(
|
|
section, done=done, next=next, where_to_look=where_to_look
|
|
),
|
|
)
|
|
return await self._write_journal_note(
|
|
agent_id=agent_id,
|
|
text=text,
|
|
scope=scope,
|
|
task_id=task_id,
|
|
structured=structured,
|
|
)
|
|
|
|
async def _write_journal_note(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
text: str,
|
|
scope: str,
|
|
task_id: UUID | None,
|
|
structured: dict[str, Any] | None,
|
|
) -> Envelope:
|
|
"""Validate + persist a journal entry for the non-handoff scopes
|
|
(note|decision|reflect|learning|struggle). Extracted from ``note`` so
|
|
both stay under the cyclomatic-complexity bound.
|
|
"""
|
|
if rej := self._reject_soup(text, field="note", min_chars=8):
|
|
return rej
|
|
if scope not in _VALID_NOTE_SCOPES:
|
|
return Envelope.invalid_state(
|
|
message=f"invalid scope {scope!r}",
|
|
remediate=f"scope must be one of: {sorted(_VALID_NOTE_SCOPES)}",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_structured_soup(scope, structured):
|
|
return rej
|
|
if task_id is not None:
|
|
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
|
|
return reject
|
|
else:
|
|
t = await self.task.get_journal_context_task_for_agent(agent_id)
|
|
if t is not None:
|
|
task_id = t.id
|
|
# Tolerate thin notes instead of rejecting them. List-typed
|
|
# fields accept a lone scalar; missing decision/reflect narrative fields
|
|
# are defaulted to a visible placeholder. The note is always recorded so
|
|
# the audit trail survives and a well-intentioned note never trips the
|
|
# do-server circuit breaker on repeated `incomplete_input`.
|
|
s = _normalize_structured(scope, structured or {})
|
|
title = (s.get("title") or text.split("\n", 1)[0])[:200]
|
|
content = _render_journal_content(scope, text, s)
|
|
await self.journal.write_entry(
|
|
agent_id=agent_id,
|
|
task_id=task_id,
|
|
scope=scope,
|
|
title=title,
|
|
content=content,
|
|
)
|
|
await self._touch_heartbeat(task_id)
|
|
return Envelope.ok(
|
|
status="noted",
|
|
task_id=str(task_id) if task_id else None,
|
|
next="continue",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _caller_role(self, agent_id: UUID) -> str:
|
|
agent = await self.task.agent_for(agent_id)
|
|
return str(agent.role) if agent is not None else ""
|
|
|
|
async def _caller_team(self, agent_id: UUID) -> Team | None:
|
|
"""The caller's Team (or None), via the same agent_for lookup
|
|
_caller_role uses. Team-scoped verbs (propose_video) need this
|
|
instead of role: Role.DEVELOPER alone can't tell a ux-dev from a
|
|
be-dev/fe-dev — the team is the only signal that distinguishes them.
|
|
"""
|
|
from roboco.foundation.identity import Team
|
|
|
|
agent = await self.task.agent_for(agent_id)
|
|
if agent is None or not agent.team:
|
|
return None
|
|
try:
|
|
return Team(agent.team)
|
|
except ValueError:
|
|
return None
|
|
|
|
async def draft_playbook(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
title: str,
|
|
problem: str,
|
|
procedure: str,
|
|
tags: list[str] | None = None,
|
|
source_task_id: UUID | None = None,
|
|
) -> Envelope:
|
|
"""Draft a curated playbook (delivery roles); the Auditor approves it."""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _DRAFT_PLAYBOOK_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=f"role {role!r} may not draft a playbook",
|
|
remediate="Only delivery roles draft playbooks; the Auditor curates.",
|
|
context_briefing={},
|
|
)
|
|
from roboco.models.playbook import PlaybookCreate
|
|
from roboco.services.base import ConflictError
|
|
from roboco.services.playbook import get_playbook_service
|
|
|
|
try:
|
|
playbook = await get_playbook_service(self.task.session).draft(
|
|
PlaybookCreate(
|
|
title=title,
|
|
problem=problem,
|
|
procedure=procedure,
|
|
tags=tags or [],
|
|
source_task_id=source_task_id,
|
|
),
|
|
created_by=agent_id,
|
|
)
|
|
except ConflictError as exc:
|
|
return Envelope.invalid_state(
|
|
message=str(exc),
|
|
remediate="Use a more distinct title (the slug must be unique).",
|
|
context_briefing={},
|
|
)
|
|
return Envelope.ok(
|
|
status="playbook_drafted",
|
|
task_id=None,
|
|
next="continue",
|
|
context_briefing={
|
|
"playbook_id": str(playbook.id),
|
|
"playbook_status": "draft",
|
|
},
|
|
)
|
|
|
|
async def approve_playbook(self, *, agent_id: UUID, playbook_id: UUID) -> Envelope:
|
|
"""Auditor approves a draft playbook (-> approved + indexed)."""
|
|
return await self._curate_playbook(
|
|
agent_id=agent_id, playbook_id=playbook_id, action="approve"
|
|
)
|
|
|
|
async def reject_playbook(
|
|
self, *, agent_id: UUID, playbook_id: UUID, reason: str
|
|
) -> Envelope:
|
|
"""Auditor rejects a playbook (-> archived, with a reason)."""
|
|
return await self._curate_playbook(
|
|
agent_id=agent_id,
|
|
playbook_id=playbook_id,
|
|
action="reject",
|
|
reason=reason,
|
|
)
|
|
|
|
async def archive_playbook(self, *, agent_id: UUID, playbook_id: UUID) -> Envelope:
|
|
"""Auditor archives an approved playbook (-> archived, retired)."""
|
|
return await self._curate_playbook(
|
|
agent_id=agent_id,
|
|
playbook_id=playbook_id,
|
|
action="archive",
|
|
)
|
|
|
|
async def _curate_playbook(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
playbook_id: UUID,
|
|
action: str,
|
|
reason: str = "",
|
|
) -> Envelope:
|
|
"""Shared Auditor-only curation path for approve / reject / archive."""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _CURATE_PLAYBOOK_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=f"role {role!r} may not curate playbooks",
|
|
remediate="Only the Auditor approves/rejects/archives playbooks.",
|
|
context_briefing={},
|
|
)
|
|
from roboco.services.base import ConflictError, NotFoundError
|
|
from roboco.services.playbook import get_playbook_service
|
|
|
|
svc = get_playbook_service(self.task.session)
|
|
try:
|
|
if action == "approve":
|
|
playbook = await svc.approve(playbook_id, approver_id=agent_id)
|
|
status = "playbook_approved"
|
|
elif action == "archive":
|
|
playbook = await svc.archive(playbook_id, approver_id=agent_id)
|
|
status = "playbook_archived"
|
|
else:
|
|
playbook = await svc.reject(
|
|
playbook_id, approver_id=agent_id, reason=reason or action
|
|
)
|
|
status = "playbook_archived"
|
|
except NotFoundError:
|
|
return Envelope.not_found(message=f"playbook {playbook_id} not found")
|
|
except ConflictError as exc:
|
|
# A status-precondition violation (approve/reject on a non-draft,
|
|
# archive on a non-approved) is a clean invalid_state, not a 500 —
|
|
# the agent gets a remediate hint to re-fetch the playbook's
|
|
# current status before re-trying.
|
|
return Envelope.invalid_state(
|
|
message=str(exc),
|
|
remediate=(
|
|
"Only a draft can be approved/rejected; only an approved "
|
|
"playbook can be archived. Re-list drafts/approved to see "
|
|
"the playbook's current status before re-trying."
|
|
),
|
|
context_briefing={"playbook_id": str(playbook_id)},
|
|
)
|
|
# Commit the status change BEFORE touching the RAG index: the index write
|
|
# runs through its own auto-committing connection, so indexing before the
|
|
# status commit would durably land (or drop) a playbook in the corpus even
|
|
# if this transaction rolled back — a divergence agents surface in
|
|
# briefings. ``get_db`` commits the session again after the route returns
|
|
# (a no-op on the now-clean transaction); this explicit commit is what
|
|
# gates the index. A poisoned session (a prior mid-verb failure rolled it
|
|
# back -> PendingRollbackError) must NOT 500 the curation verb NOR fall
|
|
# through to index an uncommitted playbook: surface a clean invalid_state
|
|
# and skip the index. See #55.
|
|
from sqlalchemy.exc import PendingRollbackError
|
|
|
|
try:
|
|
await self.task.session.commit()
|
|
except PendingRollbackError:
|
|
logger.warning(
|
|
"playbook curate: gating commit on a rolled-back session",
|
|
action=action,
|
|
playbook_id=str(playbook_id),
|
|
)
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
"the DB session was rolled back by a prior failure; the "
|
|
"playbook status change was not committed"
|
|
),
|
|
remediate=(
|
|
"re-fetch the playbook's current status and re-try the "
|
|
"curation verb"
|
|
),
|
|
context_briefing={"playbook_id": str(playbook_id)},
|
|
)
|
|
if action == "approve":
|
|
await svc.index_approved(playbook)
|
|
else:
|
|
await svc.unindex_playbook(playbook)
|
|
return Envelope.ok(
|
|
status=status,
|
|
task_id=None,
|
|
next="continue",
|
|
context_briefing={
|
|
"playbook_id": str(playbook.id),
|
|
"playbook_status": str(playbook.status),
|
|
},
|
|
)
|
|
|
|
async def curate_vault(
|
|
self, *, agent_id: UUID, task_id: UUID, narrative: str
|
|
) -> Envelope:
|
|
"""Auditor-only: write a root task-tree's vault narrative section.
|
|
|
|
Fully re-materializes the task's note (parent/subtasks/dependencies
|
|
resolved fresh) with ``narrative`` filling the ``## Narrative``
|
|
section a deterministic write otherwise leaves as a placeholder.
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _CURATE_VAULT_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=f"role {role!r} may not curate the vault",
|
|
remediate="Only the Auditor writes vault narratives.",
|
|
context_briefing={},
|
|
)
|
|
if not settings.obsidian_vault_enabled:
|
|
return Envelope.invalid_state(
|
|
message="the Obsidian vault is disabled",
|
|
remediate="ROBOCO_OBSIDIAN_VAULT_ENABLED is off — nothing to curate.",
|
|
context_briefing={},
|
|
)
|
|
task = await self.task.get(task_id)
|
|
if task is None:
|
|
return Envelope.not_found(message=f"task {task_id} not found")
|
|
|
|
from roboco.services.project import get_project_service
|
|
from roboco.services.vault_assembly import assemble_task_note_data
|
|
from roboco.services.vault_writer import get_vault_writer
|
|
|
|
try:
|
|
data = await assemble_task_note_data(
|
|
self.task,
|
|
get_project_service(self.task.session),
|
|
task,
|
|
narrative=narrative,
|
|
)
|
|
get_vault_writer().write_task(data)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"vault curation write failed", task_id=str(task_id), error=str(exc)
|
|
)
|
|
return Envelope.invalid_state(
|
|
message=f"vault write failed: {exc}",
|
|
remediate="retry curate_vault; check ROBOCO_VAULT_PATH is writable",
|
|
context_briefing={},
|
|
)
|
|
return Envelope.ok(
|
|
status="vault_curated",
|
|
task_id=str(task_id),
|
|
next="continue",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _record_section_handoff(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
text: str,
|
|
task_id: UUID | None,
|
|
structured: dict[str, Any] | None,
|
|
) -> Envelope:
|
|
"""Write the agent's dedicated note SECTION — the structured-content
|
|
counterpart to a journal note. ``note()`` only ever wrote the journal;
|
|
this is how a developer / PM / auditor authors dev_notes / quick_context
|
|
/ auditor_notes (etc.).
|
|
|
|
Routes by role to the right content type, persists through the
|
|
``apply_structured_note`` chokepoint, and also drops a journal trail
|
|
entry so the write shows in the activity log (and the auditor's
|
|
session has a signal). Validation failures return a remediation
|
|
Envelope, never a raw 422.
|
|
"""
|
|
agent = await self.task.agent_for(agent_id)
|
|
role = str(agent.role) if agent is not None else ""
|
|
content_type = content_type_for_role(role)
|
|
if content_type is None:
|
|
return Envelope.invalid_state(
|
|
message=f"role {role!r} has no dedicated note section",
|
|
remediate=(
|
|
"only developer / qa / documenter / pr_reviewer / auditor / "
|
|
"cell_pm / main_pm author a section — use scope='note' (or "
|
|
"decision/reflect/learning/struggle) for a journal entry"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if task_id is not None:
|
|
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
|
|
return reject
|
|
else:
|
|
t = await self.task.get_journal_context_task_for_agent(agent_id)
|
|
if t is None:
|
|
return Envelope.invalid_state(
|
|
message="no task to attach the section note to",
|
|
remediate="pass task_id='<the task whose section you write>'",
|
|
context_briefing={},
|
|
)
|
|
task_id = t.id
|
|
payload: dict[str, Any] = dict(structured) if structured else {"summary": text}
|
|
try:
|
|
await self.task.record_section_note(task_id, content_type, payload)
|
|
except ContentValidationError as exc:
|
|
return Envelope.invalid_state(
|
|
message=f"section note rejected: {exc.field} — {exc.reason}",
|
|
remediate=(
|
|
f"provide the section's fields via structured=... for content "
|
|
f"type {content_type!r} (resumption needs done+next; auditor "
|
|
"needs summary+severity; others need a substantive summary), "
|
|
"then retry"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
await self.journal.write_entry(
|
|
agent_id=agent_id,
|
|
task_id=task_id,
|
|
scope="note",
|
|
title=text.split("\n", 1)[0][:200] if text else f"{content_type} note",
|
|
content=text or "(structured section note)",
|
|
)
|
|
await self._touch_heartbeat(task_id)
|
|
return Envelope.ok(
|
|
status="noted",
|
|
task_id=str(task_id),
|
|
next="continue",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def pitch(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
title: str,
|
|
slug: str,
|
|
problem: str,
|
|
proposed_solution: str,
|
|
target_cells: list[str],
|
|
) -> Envelope:
|
|
"""Board (PO / Head of Marketing) proposes a product for the CEO to approve.
|
|
|
|
A pitch is content, not a lifecycle transition: it records the Board's
|
|
proposal. On CEO approval the system provisions a repo per target cell,
|
|
registers the projects, and seeds the first Main-PM task.
|
|
"""
|
|
for _pf, _pv, _min in (
|
|
("title", title, 5),
|
|
("slug", slug, 2),
|
|
("problem", problem, 15),
|
|
("proposed_solution", proposed_solution, 15),
|
|
):
|
|
if rej := self._reject_soup(_pv, field=_pf, min_chars=_min):
|
|
return rej
|
|
from pydantic import ValidationError as PydanticValidationError
|
|
|
|
from roboco.models.pitch import PitchCreate
|
|
from roboco.services.base import ConflictError, ValidationError
|
|
from roboco.services.pitch import get_pitch_service
|
|
|
|
agent = await self.task.agent_for(agent_id)
|
|
caller_role = str(agent.role) if agent is not None else ""
|
|
if caller_role not in _PITCH_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {caller_role!r} cannot pitch; only the Board "
|
|
"(product_owner / head_marketing) may propose products"
|
|
),
|
|
remediate="this verb is Board-only",
|
|
context_briefing={},
|
|
)
|
|
try:
|
|
create = PitchCreate(
|
|
title=title,
|
|
slug=slug,
|
|
problem=problem,
|
|
proposed_solution=proposed_solution,
|
|
target_cells=_coerce_pitch_cells(target_cells),
|
|
)
|
|
pitch = await get_pitch_service(self.task.session).create(
|
|
create, created_by=agent_id
|
|
)
|
|
except (
|
|
ConflictError,
|
|
ValidationError,
|
|
PydanticValidationError,
|
|
ValueError,
|
|
) as exc:
|
|
detail = getattr(exc, "message", None) or str(exc)
|
|
return Envelope.invalid_state(
|
|
message=detail,
|
|
remediate="fix the pitch fields and retry",
|
|
context_briefing={},
|
|
)
|
|
await self._notify_pitch(pitch)
|
|
return Envelope.ok(
|
|
status="proposed",
|
|
task_id=str(pitch.id),
|
|
next="await the CEO's approval in the Pitches queue",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _notify_pitch(self, pitch: Any) -> None:
|
|
"""Best-effort CEO nudge the moment a pitch is proposed — without it
|
|
a pitch rots silently until the CEO happens to open the Pitches
|
|
queue. A send failure never fails ``pitch()`` itself."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_pitch(pitch=pitch)
|
|
except Exception as exc:
|
|
logger.warning("pitch telegram notify failed (best-effort)", error=str(exc))
|
|
|
|
@classmethod
|
|
def _reject_roadmap_item_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the text + acceptance-criteria fields of one item dict."""
|
|
for field, min_chars in _ROADMAP_ITEM_TEXT_FIELDS:
|
|
value = raw.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing '{field}'",
|
|
remediate=f"provide a substantive '{field}' for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
value, field=f"item {idx} {field}", min_chars=min_chars
|
|
):
|
|
return rej
|
|
ac = raw.get("acceptance_criteria")
|
|
if (
|
|
not isinstance(ac, list)
|
|
or not ac
|
|
or not all(isinstance(c, str) and c.strip() for c in ac)
|
|
):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing acceptance_criteria",
|
|
remediate=(
|
|
f"provide a non-empty list of acceptance criteria for item {idx}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_roadmap_item_team(raw: dict[str, Any], idx: int) -> Envelope | None:
|
|
"""Validate the item's ``team`` is a known cell (backend/frontend/ux_ui)."""
|
|
from roboco.foundation.identity import CELL_TEAMS, Team
|
|
|
|
try:
|
|
team = Team(str(raw.get("team")))
|
|
except ValueError:
|
|
team = None
|
|
if team is None or team not in CELL_TEAMS:
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} has an unknown team {raw.get('team')!r}",
|
|
remediate="team must be one of: backend, frontend, ux_ui",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_roadmap_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw roadmap item dict's shape/fields; None when clean.
|
|
|
|
Synchronous — no DB access. Split from ``_reject_roadmap_item`` (which
|
|
adds the Task-6b project-exclusion check) so the shape checks stay
|
|
classmethod-testable without a session.
|
|
"""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item must be an object with title/description/"
|
|
"acceptance_criteria/project_slug/team/priority/rationale"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_roadmap_item_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_roadmap_item_team(raw, idx)
|
|
|
|
async def _reject_roadmap_item(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate one raw roadmap item dict; None when clean.
|
|
|
|
Folds the shape/fields/team checks with the Task-6b project-exclusion
|
|
check into one call so the ``propose_roadmap`` loop keeps a single
|
|
return point per item (xenon/PLR0911 budget).
|
|
"""
|
|
if rej := self._reject_roadmap_item_shape(raw, idx):
|
|
return rej
|
|
return await self._reject_excluded_roadmap_project(raw, idx)
|
|
|
|
async def _reject_excluded_roadmap_project(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Reject an item targeting a project that excluded itself from the
|
|
roadmap program (``!roadmap`` in its ``board_programs``) — the PO
|
|
learns this at propose time instead of a silent materialize-time skip.
|
|
|
|
An unresolvable ``project_slug`` is NOT rejected here; that surfaces
|
|
downstream at approve/materialize time as it already did before Task
|
|
6b (this check only ever narrows an otherwise-valid slug).
|
|
"""
|
|
from roboco.foundation.policy.board_programs import (
|
|
PROGRAMS,
|
|
project_participates,
|
|
)
|
|
from roboco.services.project import get_project_service
|
|
|
|
slug = str(raw.get("project_slug", "")).strip()
|
|
project = await get_project_service(self.task.session).get_by_slug(slug)
|
|
if project is None:
|
|
return None
|
|
if not project_participates(PROGRAMS["roadmap"], project.board_programs):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} targets project {slug!r}, which excluded "
|
|
"itself from the roadmap program"
|
|
),
|
|
remediate=(
|
|
f"drop item {idx} or retarget it to a project not "
|
|
"excluded via '!roadmap'"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_roadmap(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
cycle_goal: str,
|
|
items: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Product Owner authors a themed roadmap cycle (goal + item drafts).
|
|
|
|
Persists the cycle onto the caller's open exploration task (markers)
|
|
— each item starts 'proposed', awaiting the CEO's per-item approve/
|
|
reject in the roadmap queue. One call per cycle: the exploration task
|
|
stays open (and this verb keeps refusing) until every item is
|
|
terminal.
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _ROADMAP_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a roadmap cycle; only the "
|
|
"Product Owner authors one"
|
|
),
|
|
remediate="this verb is Product-Owner-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_soup(cycle_goal, field="cycle_goal", min_chars=8):
|
|
return rej
|
|
min_items = settings.roadmap_min_items_per_cycle
|
|
max_items = settings.roadmap_max_items_per_cycle
|
|
if not (min_items <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a cycle needs {min_items}-{max_items} item drafts, "
|
|
f"got {len(items)}"
|
|
),
|
|
remediate=f"propose between {min_items} and {max_items} roadmap items",
|
|
context_briefing={},
|
|
)
|
|
normalized: list[dict[str, Any]] = []
|
|
for idx, raw in enumerate(items):
|
|
if rej := await self._reject_roadmap_item(raw, idx):
|
|
return rej
|
|
normalized.append(_normalize_roadmap_item(idx, raw))
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_roadmap_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_roadmap_cycle(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open roadmap exploration task assigned to you",
|
|
remediate=(
|
|
"propose_roadmap only runs against an active exploration "
|
|
"cycle spawned by the roadmap engine; wait for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
markers.set_roadmap_cycle(
|
|
task, {"goal": cycle_goal.strip(), "items": normalized}
|
|
)
|
|
await self.task.session.flush()
|
|
await self._notify_roadmap_items(task, normalized)
|
|
return Envelope.ok(
|
|
status="roadmap_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each item in the roadmap queue",
|
|
context_briefing={
|
|
"cycle_goal": cycle_goal.strip(),
|
|
"item_count": len(normalized),
|
|
},
|
|
)
|
|
|
|
async def _notify_roadmap_items(
|
|
self, task: Any, items: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Best-effort push DM per proposed item — this is the moment a
|
|
roadmap item first becomes CEO-actionable (the engine's own
|
|
exploration-task origination has nothing to review yet), so the DM
|
|
fires here rather than from ``RoadmapEngine``. A send failure never
|
|
blocks ``propose_roadmap`` itself."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
id8 = str(task.id)[:8]
|
|
for item in items:
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_queue_item(
|
|
kind="roadmap",
|
|
id8=id8,
|
|
extra=str(item.get("id") or ""),
|
|
title=item.get("title") or "untitled",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"roadmap telegram notify failed (best-effort)", error=str(exc)
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_pest_hunt_item_text_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the plain text fields (title/description/project_slug/
|
|
team/evidence) of one pest-hunt item dict."""
|
|
for field, min_chars in _PEST_HUNT_ITEM_TEXT_FIELDS:
|
|
value = raw.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing '{field}'",
|
|
remediate=f"provide a substantive '{field}' for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
value, field=f"item {idx} {field}", min_chars=min_chars
|
|
):
|
|
return rej
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_pest_hunt_item_evidence_and_ac(
|
|
raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the evidence char-cap and acceptance_criteria list of one
|
|
pest-hunt item dict — split from the text-fields loop above to keep
|
|
``_reject_pest_hunt_item_fields`` under the xenon complexity budget."""
|
|
evidence = str(raw.get("evidence", ""))
|
|
if len(evidence) > _PEST_HUNT_EVIDENCE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} evidence is {len(evidence)} chars, over the "
|
|
f"{_PEST_HUNT_EVIDENCE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=(
|
|
f"shorten item {idx}'s evidence to "
|
|
f"{_PEST_HUNT_EVIDENCE_MAX_CHARS} characters or fewer"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
ac = raw.get("acceptance_criteria")
|
|
if (
|
|
not isinstance(ac, list)
|
|
or not ac
|
|
or not all(isinstance(c, str) and c.strip() for c in ac)
|
|
):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing acceptance_criteria",
|
|
remediate=(
|
|
f"provide a non-empty list of acceptance criteria for item {idx}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_pest_hunt_item_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the text + evidence-cap + acceptance-criteria fields of
|
|
one pest-hunt item dict. Mirrors ``_reject_roadmap_item_fields``."""
|
|
if rej := cls._reject_pest_hunt_item_text_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_pest_hunt_item_evidence_and_ac(raw, idx)
|
|
|
|
@classmethod
|
|
def _reject_pest_hunt_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw pest-hunt item dict's shape/fields; None when
|
|
clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is
|
|
identical for both item kinds."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item must be an object with title/description/"
|
|
"acceptance_criteria/project_slug/team/priority/evidence"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_pest_hunt_item_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_roadmap_item_team(raw, idx)
|
|
|
|
async def _reject_pest_hunt_item(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate one raw pest-hunt item dict; None when clean. Mirrors
|
|
``_reject_roadmap_item``."""
|
|
if rej := self._reject_pest_hunt_item_shape(raw, idx):
|
|
return rej
|
|
return await self._reject_unparticipating_pest_control_project(raw, idx)
|
|
|
|
async def _reject_unparticipating_pest_control_project(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Reject an item targeting a project that has NOT opted into the
|
|
pest_control program (``"pest_control"`` absent from its
|
|
``board_programs``) — the positive-gate mirror of
|
|
``_reject_excluded_roadmap_project``'s ``!roadmap`` exclusion check:
|
|
project-scoped programs are opt-in, so the polarity flips.
|
|
|
|
An unresolvable ``project_slug`` is NOT rejected here; that surfaces
|
|
downstream at approve/materialize time, same posture as roadmap's
|
|
check.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import (
|
|
PROGRAMS,
|
|
project_participates,
|
|
)
|
|
from roboco.services.project import get_project_service
|
|
|
|
slug = str(raw.get("project_slug", "")).strip()
|
|
project = await get_project_service(self.task.session).get_by_slug(slug)
|
|
if project is None:
|
|
return None
|
|
if not project_participates(PROGRAMS["pest_control"], project.board_programs):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} targets project {slug!r}, which has not "
|
|
"opted into the pest_control program"
|
|
),
|
|
remediate=(
|
|
f"drop item {idx} or ask the CEO to opt {slug!r} into "
|
|
"pest_control on its project settings page"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_bug_hunt(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
items: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Product Owner authors a Pest Control bug hunt (1-N evidence-backed
|
|
item drafts, N = the registry's ``max_items_per_cycle``).
|
|
|
|
Persists the hunt onto the caller's open exploration task (markers)
|
|
— each item starts 'proposed', awaiting the CEO's per-item approve/
|
|
reject in the pest-control queue. One call per cycle: the exploration
|
|
task stays open (and this verb keeps refusing) until every item is
|
|
terminal. Mirrors ``propose_roadmap`` — no top-level theme goal here,
|
|
just the items.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _PEST_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a bug hunt; only the "
|
|
"Product Owner authors one"
|
|
),
|
|
remediate="this verb is Product-Owner-only",
|
|
context_briefing={},
|
|
)
|
|
max_items = PROGRAMS["pest_control"].max_items_per_cycle
|
|
if not (1 <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a bug hunt needs 1-{max_items} item drafts, got {len(items)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_items} evidence-backed items",
|
|
context_briefing={},
|
|
)
|
|
normalized: list[dict[str, Any]] = []
|
|
for idx, raw in enumerate(items):
|
|
if rej := await self._reject_pest_hunt_item(raw, idx):
|
|
return rej
|
|
normalized.append(_normalize_pest_hunt_item(idx, raw))
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_pest_control_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_pest_hunt(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open pest-control exploration task assigned to you",
|
|
remediate=(
|
|
"propose_bug_hunt only runs against an active exploration "
|
|
"cycle spawned by the pest-control engine; wait for the "
|
|
"next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
markers.set_pest_hunt(task, {"items": normalized})
|
|
await self.task.session.flush()
|
|
await self._notify_pest_hunt_items(task, normalized)
|
|
return Envelope.ok(
|
|
status="pest_hunt_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each item in the pest-control queue",
|
|
context_briefing={"item_count": len(normalized)},
|
|
)
|
|
|
|
async def _notify_pest_hunt_items(
|
|
self, task: Any, items: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Best-effort push DM per proposed item — mirrors
|
|
``_notify_roadmap_items``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
id8 = str(task.id)[:8]
|
|
for item in items:
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_queue_item(
|
|
kind="pest_control",
|
|
id8=id8,
|
|
extra=str(item.get("id") or ""),
|
|
title=item.get("title") or "untitled",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"pest-control telegram notify failed (best-effort)",
|
|
error=str(exc),
|
|
)
|
|
|
|
@staticmethod
|
|
def _reject_scales_item_action_and_priority(
|
|
raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate ``action`` + the conditional ``new_priority`` requirement
|
|
of one raw rebalance item dict — split out to keep
|
|
``_reject_scales_item_shape`` under the xenon/PLR0911 budget."""
|
|
action = raw.get("action")
|
|
if action not in _SCALES_ACTIONS:
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} has an invalid action {action!r}",
|
|
remediate="action must be 'reprioritize' or 'cancel'",
|
|
context_briefing={},
|
|
)
|
|
if action != "reprioritize":
|
|
return None
|
|
new_priority = raw.get("new_priority")
|
|
if (
|
|
not isinstance(new_priority, int)
|
|
or isinstance(new_priority, bool)
|
|
or new_priority not in _SCALES_VALID_PRIORITIES
|
|
):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} is 'reprioritize' but new_priority is {new_priority!r}"
|
|
),
|
|
remediate=(
|
|
"new_priority is required for a reprioritize item and must "
|
|
"be one of 0 (P0/highest) .. 3 (P3/lowest)"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_scales_item_rationale(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the required ``rationale`` field — split out of
|
|
``_reject_scales_item_shape`` to keep its own return-statement count
|
|
under the xenon/PLR0911 budget."""
|
|
rationale = raw.get("rationale")
|
|
if not isinstance(rationale, str) or not rationale.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing 'rationale'",
|
|
remediate=f"provide a substantive rationale for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
rationale, field=f"item {idx} rationale", min_chars=8
|
|
):
|
|
return rej
|
|
if len(rationale) > _SCALES_RATIONALE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} rationale is {len(rationale)} chars, over the "
|
|
f"{_SCALES_RATIONALE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=(
|
|
f"shorten item {idx}'s rationale to "
|
|
f"{_SCALES_RATIONALE_MAX_CHARS} characters or fewer"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_scales_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw rebalance item dict's shape/fields; None when
|
|
clean (before ``task_ref`` resolution, which needs the DB)."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item must be an object with task_ref/action/"
|
|
"new_priority/rationale"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
task_ref = raw.get("task_ref")
|
|
if not isinstance(task_ref, str) or not task_ref.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing 'task_ref'",
|
|
remediate=(
|
|
f"provide the id8 or exact title of the live task item "
|
|
f"{idx} targets"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_scales_item_action_and_priority(raw, idx):
|
|
return rej
|
|
return cls._reject_scales_item_rationale(raw, idx)
|
|
|
|
async def _reject_scales_item(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> tuple[Envelope | None, Any]:
|
|
"""Validate one raw rebalance item, then resolve its ``task_ref``.
|
|
|
|
Returns ``(None, target_task)`` when clean, ``(rejection, None)``
|
|
otherwise. Resolution happens here (not a separate pass) since a
|
|
``task_ref`` only makes sense checked against a real live task.
|
|
"""
|
|
if rej := self._reject_scales_item_shape(raw, idx):
|
|
return rej, None
|
|
from roboco.services.task import get_task_service
|
|
|
|
target = await get_task_service(self.task.session).resolve_scales_task_ref(
|
|
str(raw["task_ref"]).strip()
|
|
)
|
|
if target is None:
|
|
return (
|
|
Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} task_ref {raw['task_ref']!r} does not "
|
|
"resolve to a live BACKLOG/PENDING task"
|
|
),
|
|
remediate=(
|
|
f"item {idx}'s task_ref must be the id8 or exact title "
|
|
"of a live BACKLOG/PENDING task"
|
|
),
|
|
context_briefing={},
|
|
),
|
|
None,
|
|
)
|
|
return None, target
|
|
|
|
async def propose_rebalance(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
items: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Product Owner authors a Scales portfolio-rebalance plan (1-N
|
|
re-priority/cancellation items against the LIVE backlog, N = the
|
|
registry's ``max_items_per_cycle``).
|
|
|
|
Persists the plan onto the caller's open exploration task (markers)
|
|
— each item starts 'proposed', awaiting the CEO's per-item approve/
|
|
reject in the Scales queue. One call per cycle: the exploration task
|
|
stays open (and this verb keeps refusing) until every item is
|
|
terminal. Unlike ``propose_roadmap``/``propose_bug_hunt`` an item
|
|
never drafts a NEW task — it references a LIVE one (``task_ref``,
|
|
resolved to a real BACKLOG/PENDING task here) that approval MUTATES
|
|
(reprioritize) or cancels, never creates.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _SCALES_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a rebalance plan; only the "
|
|
"Product Owner authors one"
|
|
),
|
|
remediate="this verb is Product-Owner-only",
|
|
context_briefing={},
|
|
)
|
|
max_items = PROGRAMS["scales"].max_items_per_cycle
|
|
if not (1 <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a rebalance plan needs 1-{max_items} item drafts, got "
|
|
f"{len(items)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_items} items",
|
|
context_briefing={},
|
|
)
|
|
normalized: list[dict[str, Any]] = []
|
|
for idx, raw in enumerate(items):
|
|
rejection, target = await self._reject_scales_item(raw, idx)
|
|
if rejection is not None:
|
|
return rejection
|
|
normalized.append(_normalize_scales_item(idx, raw, target))
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_scales_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_rebalance_plan(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open scales exploration task assigned to you",
|
|
remediate=(
|
|
"propose_rebalance only runs against an active exploration "
|
|
"cycle spawned by the scales engine; wait for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
markers.set_rebalance_plan(task, {"items": normalized})
|
|
await self.task.session.flush()
|
|
await self._notify_rebalance_items(task, normalized)
|
|
return Envelope.ok(
|
|
status="rebalance_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each item in the Scales queue",
|
|
context_briefing={"item_count": len(normalized)},
|
|
)
|
|
|
|
async def _notify_rebalance_items(
|
|
self, task: Any, items: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Best-effort push DM per proposed item — mirrors
|
|
``_notify_pest_hunt_items``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
id8 = str(task.id)[:8]
|
|
for item in items:
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_queue_item(
|
|
kind="scales",
|
|
id8=id8,
|
|
extra=str(item.get("id") or ""),
|
|
title=item.get("target_task_title") or "untitled",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"scales telegram notify failed (best-effort)", error=str(exc)
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_gap_fill_item_text_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the plain text fields (title/description/project_slug/
|
|
team/evidence) of one gap-fill item dict. Mirrors
|
|
``_reject_pest_hunt_item_text_fields``."""
|
|
for field, min_chars in _GAP_FILL_ITEM_TEXT_FIELDS:
|
|
value = raw.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing '{field}'",
|
|
remediate=f"provide a substantive '{field}' for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
value, field=f"item {idx} {field}", min_chars=min_chars
|
|
):
|
|
return rej
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_gap_fill_item_evidence_and_ac(
|
|
raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the evidence char-cap and acceptance_criteria list of one
|
|
gap-fill item dict — split from the text-fields loop above to keep
|
|
``_reject_gap_fill_item_fields`` under the xenon complexity budget."""
|
|
evidence = str(raw.get("evidence", ""))
|
|
if len(evidence) > _GAP_FILL_EVIDENCE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} evidence is {len(evidence)} chars, over the "
|
|
f"{_GAP_FILL_EVIDENCE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=(
|
|
f"shorten item {idx}'s evidence to "
|
|
f"{_GAP_FILL_EVIDENCE_MAX_CHARS} characters or fewer"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
ac = raw.get("acceptance_criteria")
|
|
if (
|
|
not isinstance(ac, list)
|
|
or not ac
|
|
or not all(isinstance(c, str) and c.strip() for c in ac)
|
|
):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing acceptance_criteria",
|
|
remediate=(
|
|
f"provide a non-empty list of acceptance criteria for item {idx}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_gap_fill_item_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the text + evidence-cap + acceptance-criteria fields of
|
|
one gap-fill item dict. Mirrors ``_reject_pest_hunt_item_fields``."""
|
|
if rej := cls._reject_gap_fill_item_text_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_gap_fill_item_evidence_and_ac(raw, idx)
|
|
|
|
@classmethod
|
|
def _reject_gap_fill_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw gap-fill item dict's shape/fields; None when
|
|
clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is
|
|
identical for every item kind."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item must be an object with title/description/"
|
|
"acceptance_criteria/project_slug/team/priority/evidence"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_gap_fill_item_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_roadmap_item_team(raw, idx)
|
|
|
|
async def _reject_gap_fill_item(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate one raw gap-fill item dict; None when clean. Mirrors
|
|
``_reject_pest_hunt_item``."""
|
|
if rej := self._reject_gap_fill_item_shape(raw, idx):
|
|
return rej
|
|
return await self._reject_unparticipating_spackle_project(raw, idx)
|
|
|
|
async def _reject_unparticipating_spackle_project(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Reject an item targeting a project that has NOT opted into the
|
|
spackle program (``"spackle"`` absent from its ``board_programs``) —
|
|
mirrors ``_reject_unparticipating_pest_control_project``.
|
|
|
|
An unresolvable ``project_slug`` is NOT rejected here; that surfaces
|
|
downstream at approve/materialize time, same posture as pest_control's
|
|
check.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import (
|
|
PROGRAMS,
|
|
project_participates,
|
|
)
|
|
from roboco.services.project import get_project_service
|
|
|
|
slug = str(raw.get("project_slug", "")).strip()
|
|
project = await get_project_service(self.task.session).get_by_slug(slug)
|
|
if project is None:
|
|
return None
|
|
if not project_participates(PROGRAMS["spackle"], project.board_programs):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} targets project {slug!r}, which has not "
|
|
"opted into the spackle program"
|
|
),
|
|
remediate=(
|
|
f"drop item {idx} or ask the CEO to opt {slug!r} into "
|
|
"spackle on its project settings page"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_gap_fill(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
items: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Product Owner authors a Spackle gap-fill audit (1-N evidence-backed
|
|
item drafts, N = the registry's ``max_items_per_cycle``).
|
|
|
|
Persists the audit onto the caller's open exploration task (markers)
|
|
— each item starts 'proposed', awaiting the CEO's per-item approve/
|
|
reject in the spackle queue. One call per cycle: the exploration
|
|
task stays open (and this verb keeps refusing) until every item is
|
|
terminal. Mirrors ``propose_bug_hunt`` — no top-level theme goal
|
|
here, just the items.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _GAP_FILL_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a gap-fill audit; only "
|
|
"the Product Owner authors one"
|
|
),
|
|
remediate="this verb is Product-Owner-only",
|
|
context_briefing={},
|
|
)
|
|
max_items = PROGRAMS["spackle"].max_items_per_cycle
|
|
if not (1 <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a gap-fill audit needs 1-{max_items} item drafts, "
|
|
f"got {len(items)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_items} evidence-backed items",
|
|
context_briefing={},
|
|
)
|
|
normalized: list[dict[str, Any]] = []
|
|
for idx, raw in enumerate(items):
|
|
if rej := await self._reject_gap_fill_item(raw, idx):
|
|
return rej
|
|
normalized.append(_normalize_gap_fill_item(idx, raw))
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_spackle_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_gap_fill(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open spackle exploration task assigned to you",
|
|
remediate=(
|
|
"propose_gap_fill only runs against an active exploration "
|
|
"cycle spawned by the spackle engine; wait for the next "
|
|
"cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
markers.set_gap_fill(task, {"items": normalized})
|
|
await self.task.session.flush()
|
|
await self._notify_gap_fill_items(task, normalized)
|
|
return Envelope.ok(
|
|
status="gap_fill_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each item in the spackle queue",
|
|
context_briefing={"item_count": len(normalized)},
|
|
)
|
|
|
|
async def _notify_gap_fill_items(
|
|
self, task: Any, items: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Best-effort push DM per proposed item — mirrors
|
|
``_notify_pest_hunt_items``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
id8 = str(task.id)[:8]
|
|
for item in items:
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_queue_item(
|
|
kind="spackle",
|
|
id8=id8,
|
|
extra=str(item.get("id") or ""),
|
|
title=item.get("title") or "untitled",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"spackle telegram notify failed (best-effort)",
|
|
error=str(exc),
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_messaging_fix_item_text_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the plain text fields (title/description/project_slug/
|
|
team/evidence) of one messaging-fix item dict. Mirrors
|
|
``_reject_gap_fill_item_text_fields``."""
|
|
for field, min_chars in _MESSAGING_FIX_ITEM_TEXT_FIELDS:
|
|
value = raw.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing '{field}'",
|
|
remediate=f"provide a substantive '{field}' for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
value, field=f"item {idx} {field}", min_chars=min_chars
|
|
):
|
|
return rej
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_messaging_fix_item_evidence_and_ac(
|
|
raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the evidence char-cap and acceptance_criteria list of one
|
|
messaging-fix item dict — split from the text-fields loop above to
|
|
keep ``_reject_messaging_fix_item_fields`` under the xenon complexity
|
|
budget."""
|
|
evidence = str(raw.get("evidence", ""))
|
|
if len(evidence) > _MESSAGING_FIX_EVIDENCE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} evidence is {len(evidence)} chars, over the "
|
|
f"{_MESSAGING_FIX_EVIDENCE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=(
|
|
f"shorten item {idx}'s evidence to "
|
|
f"{_MESSAGING_FIX_EVIDENCE_MAX_CHARS} characters or fewer"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
ac = raw.get("acceptance_criteria")
|
|
if (
|
|
not isinstance(ac, list)
|
|
or not ac
|
|
or not all(isinstance(c, str) and c.strip() for c in ac)
|
|
):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing acceptance_criteria",
|
|
remediate=(
|
|
f"provide a non-empty list of acceptance criteria for item {idx}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_messaging_fix_item_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the text + evidence-cap + acceptance-criteria fields of
|
|
one messaging-fix item dict. Mirrors ``_reject_gap_fill_item_fields``."""
|
|
if rej := cls._reject_messaging_fix_item_text_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_messaging_fix_item_evidence_and_ac(raw, idx)
|
|
|
|
@classmethod
|
|
def _reject_messaging_fix_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw messaging-fix item dict's shape/fields; None when
|
|
clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is
|
|
identical for every item kind."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item must be an object with title/description/"
|
|
"acceptance_criteria/project_slug/team/priority/evidence"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_messaging_fix_item_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_roadmap_item_team(raw, idx)
|
|
|
|
async def _reject_messaging_fix_item(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate one raw messaging-fix item dict; None when clean. Mirrors
|
|
``_reject_gap_fill_item``."""
|
|
if rej := self._reject_messaging_fix_item_shape(raw, idx):
|
|
return rej
|
|
return await self._reject_unparticipating_mirror_project(raw, idx)
|
|
|
|
async def _reject_unparticipating_mirror_project(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Reject an item targeting a project that has NOT opted into the
|
|
mirror program (``"mirror"`` absent from its ``board_programs``) —
|
|
mirrors ``_reject_unparticipating_spackle_project``.
|
|
|
|
An unresolvable ``project_slug`` is NOT rejected here; that surfaces
|
|
downstream at approve/materialize time, same posture as spackle's
|
|
check.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import (
|
|
PROGRAMS,
|
|
project_participates,
|
|
)
|
|
from roboco.services.project import get_project_service
|
|
|
|
slug = str(raw.get("project_slug", "")).strip()
|
|
project = await get_project_service(self.task.session).get_by_slug(slug)
|
|
if project is None:
|
|
return None
|
|
if not project_participates(PROGRAMS["mirror"], project.board_programs):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} targets project {slug!r}, which has not "
|
|
"opted into the mirror program"
|
|
),
|
|
remediate=(
|
|
f"drop item {idx} or ask the CEO to opt {slug!r} into "
|
|
"mirror on its project settings page"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_messaging_fixes(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
items: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Head of Marketing authors a Mirror positioning audit (1-N
|
|
evidence-backed item drafts, N = the registry's
|
|
``max_items_per_cycle``).
|
|
|
|
Persists the audit onto the caller's open exploration task (markers)
|
|
— each item starts 'proposed', awaiting the CEO's per-item approve/
|
|
reject in the mirror queue. One call per cycle: the exploration
|
|
task stays open (and this verb keeps refusing) until every item is
|
|
terminal. Mirrors ``propose_gap_fill`` — no top-level theme goal
|
|
here, just the items.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _MESSAGING_FIXES_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose messaging fixes; only "
|
|
"the Head of Marketing authors this audit"
|
|
),
|
|
remediate="this verb is Head-of-Marketing-only",
|
|
context_briefing={},
|
|
)
|
|
max_items = PROGRAMS["mirror"].max_items_per_cycle
|
|
if not (1 <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a messaging-fixes audit needs 1-{max_items} item drafts, "
|
|
f"got {len(items)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_items} evidence-backed items",
|
|
context_briefing={},
|
|
)
|
|
normalized: list[dict[str, Any]] = []
|
|
for idx, raw in enumerate(items):
|
|
if rej := await self._reject_messaging_fix_item(raw, idx):
|
|
return rej
|
|
normalized.append(_normalize_messaging_fix_item(idx, raw))
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_mirror_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_messaging_fixes(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open mirror exploration task assigned to you",
|
|
remediate=(
|
|
"propose_messaging_fixes only runs against an active "
|
|
"exploration cycle spawned by the mirror engine; wait "
|
|
"for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
markers.set_messaging_fixes(task, {"items": normalized})
|
|
await self.task.session.flush()
|
|
await self._notify_messaging_fix_items(task, normalized)
|
|
return Envelope.ok(
|
|
status="messaging_fixes_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each item in the mirror queue",
|
|
context_briefing={"item_count": len(normalized)},
|
|
)
|
|
|
|
async def _notify_messaging_fix_items(
|
|
self, task: Any, items: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Best-effort push DM per proposed item — mirrors
|
|
``_notify_gap_fill_items``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
id8 = str(task.id)[:8]
|
|
for item in items:
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_queue_item(
|
|
kind="mirror",
|
|
id8=id8,
|
|
extra=str(item.get("id") or ""),
|
|
title=item.get("title") or "untitled",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"mirror telegram notify failed (best-effort)",
|
|
error=str(exc),
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_friction_fix_item_text_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the plain text fields (title/description/project_slug/
|
|
team/evidence) of one friction-fix item dict. Mirrors
|
|
``_reject_messaging_fix_item_text_fields``."""
|
|
for field, min_chars in _FRICTION_FIXES_ITEM_TEXT_FIELDS:
|
|
value = raw.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing '{field}'",
|
|
remediate=f"provide a substantive '{field}' for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
value, field=f"item {idx} {field}", min_chars=min_chars
|
|
):
|
|
return rej
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_friction_fix_item_evidence_and_ac(
|
|
raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the evidence char-cap and acceptance_criteria list of one
|
|
friction-fix item dict — split from the text-fields loop above to
|
|
keep ``_reject_friction_fix_item_fields`` under the xenon complexity
|
|
budget."""
|
|
evidence = str(raw.get("evidence", ""))
|
|
if len(evidence) > _FRICTION_FIXES_EVIDENCE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} evidence is {len(evidence)} chars, over the "
|
|
f"{_FRICTION_FIXES_EVIDENCE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=(
|
|
f"shorten item {idx}'s evidence to "
|
|
f"{_FRICTION_FIXES_EVIDENCE_MAX_CHARS} characters or fewer"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
ac = raw.get("acceptance_criteria")
|
|
if (
|
|
not isinstance(ac, list)
|
|
or not ac
|
|
or not all(isinstance(c, str) and c.strip() for c in ac)
|
|
):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing acceptance_criteria",
|
|
remediate=(
|
|
f"provide a non-empty list of acceptance criteria for item {idx}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_friction_fix_item_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the text + evidence-cap + acceptance-criteria fields of
|
|
one friction-fix item dict. Mirrors ``_reject_messaging_fix_item_fields``."""
|
|
if rej := cls._reject_friction_fix_item_text_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_friction_fix_item_evidence_and_ac(raw, idx)
|
|
|
|
@classmethod
|
|
def _reject_friction_fix_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw friction-fix item dict's shape/fields; None when
|
|
clean. Reuses ``_reject_roadmap_item_team`` — the cell-team check is
|
|
identical for every item kind."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item must be an object with title/description/"
|
|
"acceptance_criteria/project_slug/team/priority/evidence"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_friction_fix_item_fields(raw, idx):
|
|
return rej
|
|
return cls._reject_roadmap_item_team(raw, idx)
|
|
|
|
async def _reject_friction_fix_item(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate one raw friction-fix item dict; None when clean. Mirrors
|
|
``_reject_messaging_fix_item``."""
|
|
if rej := self._reject_friction_fix_item_shape(raw, idx):
|
|
return rej
|
|
return await self._reject_unparticipating_dogfood_project(raw, idx)
|
|
|
|
async def _reject_unparticipating_dogfood_project(
|
|
self, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Reject an item targeting a project that has NOT opted into the
|
|
dogfood program (``"dogfood"`` absent from its ``board_programs``) —
|
|
mirrors ``_reject_unparticipating_mirror_project``.
|
|
|
|
An unresolvable ``project_slug`` is NOT rejected here; that surfaces
|
|
downstream at approve/materialize time, same posture as mirror's
|
|
check.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import (
|
|
PROGRAMS,
|
|
project_participates,
|
|
)
|
|
from roboco.services.project import get_project_service
|
|
|
|
slug = str(raw.get("project_slug", "")).strip()
|
|
project = await get_project_service(self.task.session).get_by_slug(slug)
|
|
if project is None:
|
|
return None
|
|
if not project_participates(PROGRAMS["dogfood"], project.board_programs):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} targets project {slug!r}, which has not "
|
|
"opted into the dogfood program"
|
|
),
|
|
remediate=(
|
|
f"drop item {idx} or ask the CEO to opt {slug!r} into "
|
|
"dogfood on its project settings page"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_friction_fixes(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
items: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Product Owner authors a Dogfood friction audit (1-N evidence-backed
|
|
item drafts, N = the registry's ``max_items_per_cycle``).
|
|
|
|
Persists the audit onto the caller's open exploration task (markers)
|
|
— each item starts 'proposed', awaiting the CEO's per-item approve/
|
|
reject in the dogfood queue. One call per cycle: the exploration
|
|
task stays open (and this verb keeps refusing) until every item is
|
|
terminal. Mirrors ``propose_messaging_fixes`` — no top-level theme
|
|
goal here, just the items.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _DOGFOOD_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose friction fixes; only "
|
|
"the Product Owner authors this audit"
|
|
),
|
|
remediate="this verb is Product-Owner-only",
|
|
context_briefing={},
|
|
)
|
|
max_items = PROGRAMS["dogfood"].max_items_per_cycle
|
|
if not (1 <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a friction audit needs 1-{max_items} item drafts, "
|
|
f"got {len(items)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_items} evidence-backed items",
|
|
context_briefing={},
|
|
)
|
|
normalized: list[dict[str, Any]] = []
|
|
for idx, raw in enumerate(items):
|
|
if rej := await self._reject_friction_fix_item(raw, idx):
|
|
return rej
|
|
normalized.append(_normalize_friction_fix_item(idx, raw))
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_dogfood_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_friction_fixes(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open dogfood exploration task assigned to you",
|
|
remediate=(
|
|
"propose_friction_fixes only runs against an active "
|
|
"exploration cycle spawned by the dogfood engine; wait "
|
|
"for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
markers.set_friction_fixes(task, {"items": normalized})
|
|
await self.task.session.flush()
|
|
await self._notify_friction_fix_items(task, normalized)
|
|
return Envelope.ok(
|
|
status="friction_fixes_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each item in the dogfood queue",
|
|
context_briefing={"item_count": len(normalized)},
|
|
)
|
|
|
|
async def _notify_friction_fix_items(
|
|
self, task: Any, items: list[dict[str, Any]]
|
|
) -> None:
|
|
"""Best-effort push DM per proposed item — mirrors
|
|
``_notify_messaging_fix_items``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
id8 = str(task.id)[:8]
|
|
for item in items:
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_queue_item(
|
|
kind="dogfood",
|
|
id8=id8,
|
|
extra=str(item.get("id") or ""),
|
|
title=item.get("title") or "untitled",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"dogfood telegram notify failed (best-effort)",
|
|
error=str(exc),
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_feature_spotlight_fields(
|
|
cls, feature_slug: str, feature_title: str, body: str
|
|
) -> Envelope | None:
|
|
"""Soup + 280-char validation for a spotlight draft's free-text fields,
|
|
collapsed into one caller-side check (keeps propose_feature_spotlight's
|
|
return-statement count under the xenon/PLR0911 budget)."""
|
|
if rej := cls._reject_soup(feature_slug, field="feature_slug", min_chars=2):
|
|
return rej
|
|
if rej := cls._reject_soup(feature_title, field="feature_title", min_chars=4):
|
|
return rej
|
|
if rej := cls._reject_soup(body, field="body", min_chars=8):
|
|
return rej
|
|
if len(body) > MAX_TWEET_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"body is {len(body)} chars, over the {MAX_TWEET_CHARS}-char "
|
|
"tweet limit"
|
|
),
|
|
remediate="shorten the post to 280 characters or fewer",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def _propose_feature_spotlight_skip(
|
|
self, agent_id: UUID, skip_reason: str
|
|
) -> Envelope:
|
|
"""``skip=True`` branch of ``propose_feature_spotlight``: validate the
|
|
reason, find the caller's open exploration, and record the skip.
|
|
Split out to keep the caller's return-statement count under the
|
|
xenon/PLR0911 budget."""
|
|
if rej := self._reject_soup(skip_reason, field="skip_reason", min_chars=8):
|
|
return rej
|
|
|
|
from roboco.services.task import get_task_service
|
|
from roboco.services.x_engine import get_x_engine
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
explorations = await task_svc.list_open_feature_explorations()
|
|
task = next((t for t in explorations if t.assigned_to == agent_id), None)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open feature-spotlight exploration task assigned to you",
|
|
remediate=(
|
|
"propose_feature_spotlight only runs against an active "
|
|
"exploration spawned by the X engine; wait for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
engine = get_x_engine(self.task.session)
|
|
await engine.skip_feature_spotlight(exploration_task=task, reason=skip_reason)
|
|
return Envelope.ok(
|
|
status="feature_spotlight_skipped",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — no draft was materialized this cycle",
|
|
context_briefing={"skip_reason": skip_reason},
|
|
)
|
|
|
|
async def propose_feature_spotlight(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
feature_slug: str = "",
|
|
feature_title: str = "",
|
|
body: str = "",
|
|
wants_video: bool = False,
|
|
video_script: str = "",
|
|
skip: bool = False,
|
|
skip_reason: str = "",
|
|
) -> Envelope:
|
|
"""Head of Marketing authors ONE feature-spotlight draft, or skips.
|
|
|
|
Validates role, field lengths, the 280-char tweet limit, and that the
|
|
feature hasn't already been covered, then materializes the held X-queue
|
|
draft and completes the caller's exploration task. One call per cycle.
|
|
|
|
``skip=True`` is the "nothing worth spotlighting this cycle" exit — a
|
|
forced, weak spotlight is worse than skipping one. It requires a
|
|
substantive ``skip_reason`` and ignores ``feature_slug``/
|
|
``feature_title``/``body``/``wants_video``/``video_script`` entirely:
|
|
no draft is materialized, no feature is marked seen, but the
|
|
exploration task still completes (``XEngine.skip_feature_spotlight``)
|
|
so the skip counts as this cycle's activity for the engine's
|
|
smart-cadence guard.
|
|
|
|
``wants_video`` optionally requests a companion video. The video
|
|
authoring task no longer opens here — it opens later, at CEO-approve
|
|
time (``XPostService._open_spotlight_video``, gated on
|
|
``video_engine_enabled AND video_on_spotlight``), so a ux-dev never
|
|
burns a cycle on a spotlight the CEO then rejects. This verb only
|
|
records the request (+ optional script) on the draft's
|
|
``x_feature_ref`` marker for approve time to read. Defaults leave the
|
|
flow byte-for-byte unchanged.
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _FEATURE_SPOTLIGHT_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a feature spotlight; only the "
|
|
"Head of Marketing does"
|
|
),
|
|
remediate="this verb is Head-of-Marketing-only",
|
|
context_briefing={},
|
|
)
|
|
if skip:
|
|
return await self._propose_feature_spotlight_skip(agent_id, skip_reason)
|
|
if rej := self._reject_feature_spotlight_fields(
|
|
feature_slug, feature_title, body
|
|
):
|
|
return rej
|
|
|
|
from roboco.services.task import get_task_service
|
|
from roboco.services.x_engine import get_x_engine
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
explorations = await task_svc.list_open_feature_explorations()
|
|
task = next((t for t in explorations if t.assigned_to == agent_id), None)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open feature-spotlight exploration task assigned to you",
|
|
remediate=(
|
|
"propose_feature_spotlight only runs against an active "
|
|
"exploration spawned by the X engine; wait for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
engine = get_x_engine(self.task.session)
|
|
if await engine.is_feature_seen(feature_slug):
|
|
return Envelope.invalid_state(
|
|
message=f"feature {feature_slug!r} was already covered",
|
|
remediate=(
|
|
"pick a different, not-yet-covered feature — see the "
|
|
"seen-features list in your briefing"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
new_task = await engine.materialize_feature_spotlight(
|
|
exploration_task=task,
|
|
feature_slug=feature_slug,
|
|
feature_title=feature_title,
|
|
body=body,
|
|
)
|
|
if wants_video:
|
|
# Gating (video_engine_enabled AND video_on_spotlight) and the
|
|
# actual open_video_task call happen later, at CEO-approve time —
|
|
# see XPostService._open_spotlight_video. Stash the request here
|
|
# so approve time has it; slug/title are already on this ref from
|
|
# materialize_feature_spotlight above, so re-set alongside them.
|
|
markers.set_x_feature_ref(
|
|
new_task,
|
|
{
|
|
"slug": feature_slug,
|
|
"title": feature_title,
|
|
"wants_video": True,
|
|
"video_script": video_script.strip(),
|
|
},
|
|
)
|
|
await self.task.session.flush()
|
|
return Envelope.ok(
|
|
status="feature_spotlight_proposed",
|
|
task_id=str(new_task.id),
|
|
next="i_am_idle() — the CEO reviews the draft in the X post queue",
|
|
context_briefing={
|
|
"feature_slug": feature_slug,
|
|
"feature_title": feature_title,
|
|
},
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_editorial_post_fields(
|
|
cls, angle: str, body: str, rationale: str
|
|
) -> Envelope | None:
|
|
"""Angle vocabulary + soup + 280-char validation for a Megaphone
|
|
draft's fields, collapsed into one caller-side check (keeps
|
|
propose_editorial_post's return-statement count under the
|
|
xenon/PLR0911 budget) — mirrors
|
|
``_reject_feature_spotlight_fields``."""
|
|
if angle not in _EDITORIAL_ANGLES:
|
|
return Envelope.invalid_state(
|
|
message=f"angle {angle!r} is not a recognized editorial angle",
|
|
remediate=(
|
|
"angle must be one of: " + ", ".join(sorted(_EDITORIAL_ANGLES))
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(body, field="body", min_chars=8):
|
|
return rej
|
|
if len(body) > MAX_TWEET_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"body is {len(body)} chars, over the {MAX_TWEET_CHARS}-char "
|
|
"tweet limit"
|
|
),
|
|
remediate="shorten the post to 280 characters or fewer",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(rationale, field="rationale", min_chars=8):
|
|
return rej
|
|
if len(rationale) > _EDITORIAL_RATIONALE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"rationale is {len(rationale)} chars, over the "
|
|
f"{_EDITORIAL_RATIONALE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate="shorten the rationale",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_editorial_post(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
angle: str = "",
|
|
body: str = "",
|
|
rationale: str = "",
|
|
) -> Envelope:
|
|
"""Head of Marketing authors ONE Megaphone editorial-calendar post.
|
|
|
|
Validates role, the angle vocabulary, the 280-char tweet limit, and
|
|
the rationale, then materializes the SAME held X-queue draft
|
|
``propose_feature_spotlight`` uses (via ``XEngine.
|
|
materialize_editorial_post``, source=x_editorial) and completes the
|
|
caller's exploration task in the same call — a Megaphone post has no
|
|
per-item CEO decision to leave the exploration open for, mirroring
|
|
the x_feature complete-at-propose asymmetry. One call per cycle.
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _MEGAPHONE_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose an editorial post; only "
|
|
"the Head of Marketing does"
|
|
),
|
|
remediate="this verb is Head-of-Marketing-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_editorial_post_fields(angle, body, rationale):
|
|
return rej
|
|
|
|
from roboco.services.task import get_task_service
|
|
from roboco.services.x_engine import get_x_engine
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_megaphone_cycles()
|
|
task = next((t for t in cycles if t.assigned_to == agent_id), None)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open megaphone exploration task assigned to you",
|
|
remediate=(
|
|
"propose_editorial_post only runs against an active "
|
|
"exploration spawned by the megaphone engine; wait for "
|
|
"the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
engine = get_x_engine(self.task.session)
|
|
new_task = await engine.materialize_editorial_post(
|
|
exploration_task=task, angle=angle, body=body, rationale=rationale
|
|
)
|
|
return Envelope.ok(
|
|
status="editorial_post_proposed",
|
|
task_id=str(new_task.id),
|
|
next="i_am_idle() — the CEO reviews the draft in the X post queue",
|
|
context_briefing={"angle": angle, "rationale": rationale},
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_barfly_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw conversation-reply item dict's shape; None when
|
|
clean (before ``tweet_id`` resolution against the task's real
|
|
candidates, which needs the task in hand)."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item must be an object with tweet_id/reply_body/rationale"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
tweet_id = raw.get("tweet_id")
|
|
if not isinstance(tweet_id, str) or not tweet_id.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing 'tweet_id'",
|
|
remediate=(
|
|
f"item {idx}'s tweet_id must name one of the candidate "
|
|
"conversations already on this task"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
reply_body = raw.get("reply_body")
|
|
if not isinstance(reply_body, str) or not reply_body.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing 'reply_body'",
|
|
remediate=f"provide a substantive reply_body for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
reply_body, field=f"item {idx} reply_body", min_chars=8
|
|
):
|
|
return rej
|
|
if len(reply_body) > MAX_TWEET_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} reply_body is {len(reply_body)} chars, over "
|
|
f"the {MAX_TWEET_CHARS}-char tweet limit"
|
|
),
|
|
remediate=f"shorten item {idx}'s reply_body to {MAX_TWEET_CHARS} chars",
|
|
context_briefing={},
|
|
)
|
|
return cls._reject_barfly_item_rationale(raw, idx)
|
|
|
|
@classmethod
|
|
def _reject_barfly_item_rationale(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate the required ``rationale`` field — split out of
|
|
``_reject_barfly_item_shape`` to keep its own return-statement count
|
|
under the xenon/PLR0911 budget."""
|
|
rationale = raw.get("rationale")
|
|
if not isinstance(rationale, str) or not rationale.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing 'rationale'",
|
|
remediate=f"provide a substantive rationale for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
rationale, field=f"item {idx} rationale", min_chars=8
|
|
):
|
|
return rej
|
|
if len(rationale) > _BARFLY_RATIONALE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} rationale is {len(rationale)} chars, over "
|
|
f"the {_BARFLY_RATIONALE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=f"shorten item {idx}'s rationale",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_barfly_item_candidate(
|
|
raw: dict[str, Any], idx: int, candidates_by_id: dict[str, dict[str, Any]]
|
|
) -> Envelope | None:
|
|
"""Reject a ``tweet_id`` that doesn't name one of THIS cycle's real
|
|
screened candidates — the agent must reply to what was actually
|
|
found, never invent a tweet. Split out so
|
|
``_reject_barfly_item``'s xenon budget stays flat."""
|
|
tweet_id = str(raw["tweet_id"]).strip()
|
|
if tweet_id in candidates_by_id:
|
|
return None
|
|
valid = ", ".join(sorted(candidates_by_id)) or "(none)"
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} tweet_id {tweet_id!r} does not match any "
|
|
"candidate conversation on this task"
|
|
),
|
|
remediate=f"item {idx}'s tweet_id must be one of: {valid}",
|
|
context_briefing={},
|
|
)
|
|
|
|
def _reject_barfly_caller_and_bounds(
|
|
self, role: str, items: list[dict[str, Any]], max_items: int
|
|
) -> Envelope | None:
|
|
"""Role gate + item-count bounds — split out so the main verb's own
|
|
branch count stays flat (xenon budget)."""
|
|
if role not in _BARFLY_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose conversation replies; only "
|
|
"the Head of Marketing authors them"
|
|
),
|
|
remediate="this verb is Head-of-Marketing-only",
|
|
context_briefing={},
|
|
)
|
|
if not (1 <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"conversation replies need 1-{max_items} item drafts, "
|
|
f"got {len(items)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_items} drafted replies",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_barfly_item_shapes(cls, items: list[dict[str, Any]]) -> Envelope | None:
|
|
"""Pure, DB-free pass over every item's shape — split out so the
|
|
main verb's own branch count stays flat (xenon budget)."""
|
|
for idx, raw in enumerate(items):
|
|
if rej := cls._reject_barfly_item_shape(raw, idx):
|
|
return rej
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_barfly_item_candidates(
|
|
items: list[dict[str, Any]], candidates_by_id: dict[str, dict[str, Any]]
|
|
) -> Envelope | None:
|
|
"""Second pass, once the exploration task (and so its real
|
|
candidates) is in hand — split out so the main verb's own branch
|
|
count stays flat (xenon budget)."""
|
|
for idx, raw in enumerate(items):
|
|
if rej := ContentActions._reject_barfly_item_candidate(
|
|
raw, idx, candidates_by_id
|
|
):
|
|
return rej
|
|
return None
|
|
|
|
@staticmethod
|
|
async def _materialize_barfly_replies(
|
|
engine: Any,
|
|
task: Any,
|
|
items: list[dict[str, Any]],
|
|
candidates_by_id: dict[str, dict[str, Any]],
|
|
) -> list[str]:
|
|
"""One held draft per approved-shape item, through the shared
|
|
``_originate_post`` chokepoint — split out so the main verb's own
|
|
branch count stays flat (xenon budget)."""
|
|
materialized_ids: list[str] = []
|
|
for raw in items:
|
|
candidate = candidates_by_id[str(raw["tweet_id"]).strip()]
|
|
new_task = await engine.materialize_barfly_reply(
|
|
exploration_task=task,
|
|
candidate=candidate,
|
|
reply_body=str(raw["reply_body"]).strip(),
|
|
rationale=str(raw["rationale"]).strip(),
|
|
)
|
|
materialized_ids.append(str(new_task.id))
|
|
return materialized_ids
|
|
|
|
async def propose_conversation_replies(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
items: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Head of Marketing drafts 1-N replies (N = the registry's
|
|
``max_items_per_cycle``) to screened X conversations Barfly's search
|
|
cycle already gathered onto the exploration task.
|
|
|
|
Validation runs in two passes, mirroring every other item-verb's
|
|
pure-then-DB split: first EVERY item's shape (dict/tweet_id/
|
|
reply_body/rationale — no DB touched), then the exploration task is
|
|
resolved, then EVERY item's ``tweet_id`` is checked against that
|
|
task's own screened candidates — an invented tweet is rejected
|
|
naming the valid ids. Unlike ``propose_gap_fill``/``propose_bug_
|
|
hunt`` (a per-item CEO queue that keeps the exploration task open)
|
|
this mirrors ``propose_feature_spotlight``'s complete-at-propose
|
|
asymmetry MULTIPLIED across every item: each approved-shape reply
|
|
materializes its own held draft (source=x_barfly) through
|
|
``XEngine.materialize_barfly_reply`` in this same call, then the
|
|
exploration task itself completes — the CEO decides each
|
|
materialized draft individually in the existing X post queue, not on
|
|
this task.
|
|
"""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
role = await self._caller_role(agent_id)
|
|
max_items = PROGRAMS["barfly"].max_items_per_cycle
|
|
if rej := self._reject_barfly_caller_and_bounds(role, items, max_items):
|
|
return rej
|
|
if rej := self._reject_barfly_item_shapes(items):
|
|
return rej
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_barfly_cycles()
|
|
task = next((t for t in cycles if t.assigned_to == agent_id), None)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open barfly exploration task assigned to you",
|
|
remediate=(
|
|
"propose_conversation_replies only runs against an active "
|
|
"exploration cycle spawned by the barfly engine; wait for "
|
|
"the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
candidates_by_id = {
|
|
str(c.get("id")): c
|
|
for c in markers.get_barfly_candidates(task)
|
|
if isinstance(c, dict) and c.get("id")
|
|
}
|
|
if rej := self._reject_barfly_item_candidates(items, candidates_by_id):
|
|
return rej
|
|
|
|
from roboco.services.x_engine import get_x_engine
|
|
|
|
engine = get_x_engine(self.task.session)
|
|
materialized_ids = await self._materialize_barfly_replies(
|
|
engine, task, items, candidates_by_id
|
|
)
|
|
task.status = TaskStatus.COMPLETED
|
|
await self.task.session.flush()
|
|
return Envelope.ok(
|
|
status="conversation_replies_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each reply in the X post queue",
|
|
context_briefing={
|
|
"item_count": len(items),
|
|
"materialized_task_ids": materialized_ids,
|
|
},
|
|
)
|
|
|
|
@staticmethod
|
|
def _reject_market_brief_url(url: str, idx: int) -> Envelope | None:
|
|
"""An uncited market claim is noise (spec Task 2) — validate
|
|
``source_url`` parses as a real http(s) URL rather than soup-checking
|
|
it as prose."""
|
|
if len(url) > _MARKET_BRIEF_FINDING_SOURCE_URL_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"finding {idx} source_url is {len(url)} chars, over the "
|
|
f"{_MARKET_BRIEF_FINDING_SOURCE_URL_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=f"shorten finding {idx}'s source_url",
|
|
context_briefing={},
|
|
)
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
return Envelope.invalid_state(
|
|
message=f"finding {idx} source_url {url!r} is not a valid http(s) URL",
|
|
remediate=(
|
|
f"provide a real http(s) URL finding {idx}'s claim came from"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_market_brief_finding(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw market-brief finding dict; None when clean."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"finding {idx} is not an object",
|
|
remediate="each finding needs claim/source_url/relevance",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_market_brief_finding_claim(raw, idx):
|
|
return rej
|
|
if rej := cls._reject_market_brief_finding_source(raw, idx):
|
|
return rej
|
|
return cls._reject_market_brief_finding_relevance(raw, idx)
|
|
|
|
@classmethod
|
|
def _reject_market_brief_finding_claim(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Split out of ``_reject_market_brief_finding`` to keep its
|
|
return-statement count under the xenon/PLR0911 budget."""
|
|
claim = raw.get("claim")
|
|
if not isinstance(claim, str) or not claim.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"finding {idx} is missing 'claim'",
|
|
remediate=f"provide a substantive claim for finding {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(claim, field=f"finding {idx} claim", min_chars=8):
|
|
return rej
|
|
if len(claim) > _MARKET_BRIEF_FINDING_CLAIM_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"finding {idx} claim is {len(claim)} chars, over the "
|
|
f"{_MARKET_BRIEF_FINDING_CLAIM_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=f"shorten finding {idx}'s claim",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_market_brief_finding_source(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Split out of ``_reject_market_brief_finding`` to keep its
|
|
return-statement count under the xenon/PLR0911 budget."""
|
|
source_url = raw.get("source_url")
|
|
if not isinstance(source_url, str) or not source_url.strip():
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"finding {idx} is missing 'source_url' — an uncited "
|
|
"market claim is noise"
|
|
),
|
|
remediate=(
|
|
f"provide the http(s) source URL finding {idx}'s claim came from"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return cls._reject_market_brief_url(source_url, idx)
|
|
|
|
@classmethod
|
|
def _reject_market_brief_finding_relevance(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Split out of ``_reject_market_brief_finding`` to keep its
|
|
return-statement count under the xenon/PLR0911 budget."""
|
|
relevance = raw.get("relevance")
|
|
if not isinstance(relevance, str) or not relevance.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"finding {idx} is missing 'relevance'",
|
|
remediate=f"provide a substantive relevance for finding {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
relevance, field=f"finding {idx} relevance", min_chars=8
|
|
):
|
|
return rej
|
|
if len(relevance) > _MARKET_BRIEF_FINDING_RELEVANCE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"finding {idx} relevance is {len(relevance)} chars, over "
|
|
f"the {_MARKET_BRIEF_FINDING_RELEVANCE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=f"shorten finding {idx}'s relevance",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_market_brief_text_list(
|
|
cls, values: Any, *, field: str
|
|
) -> Envelope | None:
|
|
"""Validate an optional ``threats``/``opportunities`` list: at most
|
|
``_MARKET_BRIEF_LIST_MAX_ITEMS`` substantive strings, each capped at
|
|
``_MARKET_BRIEF_LIST_ITEM_MAX_CHARS``. ``None`` (omitted) is clean."""
|
|
if values is None:
|
|
return None
|
|
if not isinstance(values, list) or len(values) > _MARKET_BRIEF_LIST_MAX_ITEMS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"{field} must be a list of at most "
|
|
f"{_MARKET_BRIEF_LIST_MAX_ITEMS} strings"
|
|
),
|
|
remediate=f"provide at most {_MARKET_BRIEF_LIST_MAX_ITEMS} {field}",
|
|
context_briefing={},
|
|
)
|
|
for i, v in enumerate(values):
|
|
if rej := cls._reject_market_brief_list_item(v, field=field, idx=i):
|
|
return rej
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_market_brief_list_item(
|
|
cls, value: Any, *, field: str, idx: int
|
|
) -> Envelope | None:
|
|
"""One ``threats``/``opportunities`` entry — split out of
|
|
``_reject_market_brief_text_list`` to keep its own return-statement
|
|
count under the xenon/PLR0911 budget."""
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"{field}[{idx}] is empty",
|
|
remediate=f"provide substantive text for {field}[{idx}] or drop it",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(value, field=f"{field}[{idx}]", min_chars=4):
|
|
return rej
|
|
if len(value) > _MARKET_BRIEF_LIST_ITEM_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"{field}[{idx}] is {len(value)} chars, over the "
|
|
f"{_MARKET_BRIEF_LIST_ITEM_MAX_CHARS}-char cap"
|
|
),
|
|
remediate=f"shorten {field}[{idx}]",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_market_brief(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
headline: str,
|
|
findings: list[dict[str, Any]],
|
|
threats: list[str] | None = None,
|
|
opportunities: list[str] | None = None,
|
|
positioning_note: str = "",
|
|
) -> Envelope:
|
|
"""Head of Marketing files ONE Periscope weekly market-research brief
|
|
— competitors, adjacent-tool releases, positioning shifts — delivered
|
|
as a held REPORT to the CEO.
|
|
|
|
Unlike ``propose_roadmap``/``propose_bug_hunt`` (a per-item CEO queue
|
|
that keeps the exploration task open until every item is decided),
|
|
this mirrors ``propose_feature_spotlight``'s complete-at-propose
|
|
asymmetry: a report has no per-item decision, so the exploration task
|
|
completes in this same call. The brief is screened through
|
|
``injection_guard.screen_external_text`` before persisting — it is
|
|
web-derived content that later reaches the roadmap exploration
|
|
prompt, same untrusted-text posture as X mentions / vault notes
|
|
(screen-and-flag, never drop).
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _PERISCOPE_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a market brief; only the "
|
|
"Head of Marketing authors one"
|
|
),
|
|
remediate="this verb is Head-of-Marketing-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_market_brief_fields(
|
|
headline, findings, threats, opportunities, positioning_note
|
|
):
|
|
return rej
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_periscope_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_market_brief(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open periscope exploration task assigned to you",
|
|
remediate=(
|
|
"propose_market_brief only runs against an active "
|
|
"exploration cycle spawned by the periscope engine; wait "
|
|
"for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
await self._persist_market_brief(
|
|
task,
|
|
headline=headline,
|
|
findings=findings,
|
|
threats=threats,
|
|
opportunities=opportunities,
|
|
positioning_note=positioning_note,
|
|
)
|
|
await self._notify_periscope_brief(task, headline.strip())
|
|
return Envelope.ok(
|
|
status="market_brief_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reads the brief as a report in the panel",
|
|
context_briefing={
|
|
"headline": headline.strip(),
|
|
"finding_count": len(findings),
|
|
},
|
|
)
|
|
|
|
async def _persist_market_brief(
|
|
self,
|
|
task: Any,
|
|
*,
|
|
headline: str,
|
|
findings: list[dict[str, Any]],
|
|
threats: list[str] | None,
|
|
opportunities: list[str] | None,
|
|
positioning_note: str,
|
|
) -> None:
|
|
"""Normalize, screen, persist, and complete — split out of
|
|
``propose_market_brief`` to keep its own cyclomatic complexity under
|
|
the xenon budget. Complete-at-propose: a report has no per-item CEO
|
|
decision to wait on (the x_feature asymmetry, not the roadmap/
|
|
pest-control per-item flow) — BoardProgramEngine's dedup ledger
|
|
auto-closes the cycle row the moment it next checks this now-terminal
|
|
exploration task.
|
|
"""
|
|
normalized_findings = [
|
|
_normalize_market_brief_finding(idx, raw)
|
|
for idx, raw in enumerate(findings)
|
|
]
|
|
normalized_threats = [str(t).strip() for t in (threats or [])]
|
|
normalized_opportunities = [str(o).strip() for o in (opportunities or [])]
|
|
normalized_note = positioning_note.strip()
|
|
screened = screen_external_text(
|
|
_render_market_brief_for_screening(
|
|
headline,
|
|
normalized_findings,
|
|
normalized_threats,
|
|
normalized_opportunities,
|
|
normalized_note,
|
|
),
|
|
source=f"periscope_brief:{task.id}",
|
|
)
|
|
if screened.flagged:
|
|
logger.warning(
|
|
"periscope: injection pattern detected in market brief",
|
|
task_id=str(task.id),
|
|
hits=screened.hits,
|
|
)
|
|
markers.set_market_brief(
|
|
task,
|
|
{
|
|
"headline": headline.strip(),
|
|
"findings": normalized_findings,
|
|
"threats": normalized_threats,
|
|
"opportunities": normalized_opportunities,
|
|
"positioning_note": normalized_note,
|
|
"injection_hits": screened.hits,
|
|
},
|
|
)
|
|
task.status = TaskStatus.COMPLETED
|
|
await self.task.session.flush()
|
|
|
|
@classmethod
|
|
def _reject_market_brief_findings_list(
|
|
cls, findings: list[dict[str, Any]]
|
|
) -> Envelope | None:
|
|
"""The count cap + per-finding validation loop, split out of
|
|
``_reject_market_brief_fields`` to keep its own return-statement
|
|
count under the xenon/PLR0911 budget."""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
max_findings = PROGRAMS["periscope"].max_items_per_cycle
|
|
if not (1 <= len(findings) <= max_findings):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a brief needs 1-{max_findings} cited findings, got "
|
|
f"{len(findings)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_findings} cited findings",
|
|
context_briefing={},
|
|
)
|
|
for idx, raw in enumerate(findings):
|
|
if rej := cls._reject_market_brief_finding(raw, idx):
|
|
return rej
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_market_brief_fields(
|
|
cls,
|
|
headline: str,
|
|
findings: list[dict[str, Any]],
|
|
threats: list[str] | None,
|
|
opportunities: list[str] | None,
|
|
positioning_note: str,
|
|
) -> Envelope | None:
|
|
"""Full field validation for ``propose_market_brief``, split out to
|
|
keep the verb's own return-statement count under the xenon/PLR0911
|
|
budget."""
|
|
if rej := cls._reject_soup(headline, field="headline", min_chars=8):
|
|
return rej
|
|
if len(headline) > _MARKET_BRIEF_HEADLINE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"headline is {len(headline)} chars, over the "
|
|
f"{_MARKET_BRIEF_HEADLINE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate="shorten the headline",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_market_brief_findings_list(findings):
|
|
return rej
|
|
if rej := cls._reject_market_brief_text_list(threats, field="threats"):
|
|
return rej
|
|
if rej := cls._reject_market_brief_text_list(
|
|
opportunities, field="opportunities"
|
|
):
|
|
return rej
|
|
return cls._reject_market_brief_positioning_note(positioning_note)
|
|
|
|
@classmethod
|
|
def _reject_market_brief_positioning_note(cls, value: str) -> Envelope | None:
|
|
"""Split out of ``_reject_market_brief_fields`` to keep its own
|
|
return-statement count under the xenon/PLR0911 budget. Optional —
|
|
empty is clean; only a non-empty value is soup/length-checked."""
|
|
if not value or not value.strip():
|
|
return None
|
|
if rej := cls._reject_soup(value, field="positioning_note", min_chars=8):
|
|
return rej
|
|
if len(value) > _MARKET_BRIEF_POSITIONING_NOTE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"positioning_note is {len(value)} chars, over the "
|
|
f"{_MARKET_BRIEF_POSITIONING_NOTE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate="shorten positioning_note",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def _notify_periscope_brief(self, task: Any, headline: str) -> None:
|
|
"""Best-effort CEO nudge the moment a market brief lands — ONE call
|
|
per cycle (a report, not N queue items), so unlike
|
|
``_notify_pest_hunt_items``/``_notify_roadmap_items`` this fires once,
|
|
not per-finding."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_periscope_brief(
|
|
task=task, task_id=task.id, headline=headline
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"periscope telegram notify failed (best-effort)", error=str(exc)
|
|
)
|
|
|
|
@staticmethod
|
|
def _reject_quality_report_item_area(
|
|
raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
area = raw.get("area")
|
|
if not isinstance(area, str) or area.strip() not in _QUALITY_REPORT_AREAS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} area {area!r} must be one of "
|
|
f"{sorted(_QUALITY_REPORT_AREAS)}"
|
|
),
|
|
remediate=(
|
|
f"set item {idx}'s area to one of {sorted(_QUALITY_REPORT_AREAS)}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_quality_report_item_text_fields(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Validate observation/evidence/suggested_action of one quality-
|
|
report item dict — split from ``_reject_quality_report_item`` to
|
|
keep its own xenon complexity budget."""
|
|
for field, max_chars in (
|
|
("observation", _QUALITY_REPORT_ITEM_OBSERVATION_MAX_CHARS),
|
|
("evidence", _QUALITY_REPORT_ITEM_EVIDENCE_MAX_CHARS),
|
|
("suggested_action", _QUALITY_REPORT_ITEM_SUGGESTED_ACTION_MAX_CHARS),
|
|
):
|
|
value = raw.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is missing '{field}'",
|
|
remediate=f"provide a substantive '{field}' for item {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(value, field=f"item {idx} {field}", min_chars=8):
|
|
return rej
|
|
if len(value) > max_chars:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"item {idx} {field} is {len(value)} chars, over the "
|
|
f"{max_chars}-char cap"
|
|
),
|
|
remediate=f"shorten item {idx}'s {field}",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_quality_report_item(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw quality-report item dict; None when clean.
|
|
Mirrors ``_reject_market_brief_finding``."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"item {idx} is not an object",
|
|
remediate=(
|
|
"each item needs area/observation/evidence/suggested_action"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_quality_report_item_area(raw, idx):
|
|
return rej
|
|
return cls._reject_quality_report_item_text_fields(raw, idx)
|
|
|
|
@classmethod
|
|
def _reject_quality_report_items(
|
|
cls, items: list[dict[str, Any]]
|
|
) -> Envelope | None:
|
|
"""The count cap + per-item validation loop, split out of
|
|
``_reject_quality_report_fields`` to keep its own return-statement
|
|
count under the xenon/PLR0911 budget."""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
max_items = PROGRAMS["sentinel"].max_items_per_cycle
|
|
if not (1 <= len(items) <= max_items):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a quality report needs 1-{max_items} items, got {len(items)}"
|
|
),
|
|
remediate=f"propose between 1 and {max_items} evidence-backed items",
|
|
context_briefing={},
|
|
)
|
|
for idx, raw in enumerate(items):
|
|
if rej := cls._reject_quality_report_item(raw, idx):
|
|
return rej
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_quality_report_fields(
|
|
cls, headline: str, items: list[dict[str, Any]], overall_assessment: str
|
|
) -> Envelope | None:
|
|
"""Full field validation for ``propose_quality_report``, split out to
|
|
keep the verb's own return-statement count under the xenon/PLR0911
|
|
budget."""
|
|
if rej := cls._reject_soup(headline, field="headline", min_chars=8):
|
|
return rej
|
|
if len(headline) > _QUALITY_REPORT_HEADLINE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"headline is {len(headline)} chars, over the "
|
|
f"{_QUALITY_REPORT_HEADLINE_MAX_CHARS}-char cap"
|
|
),
|
|
remediate="shorten the headline",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_quality_report_items(items):
|
|
return rej
|
|
if rej := cls._reject_soup(
|
|
overall_assessment, field="overall_assessment", min_chars=8
|
|
):
|
|
return rej
|
|
if len(overall_assessment) > _QUALITY_REPORT_OVERALL_ASSESSMENT_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"overall_assessment is {len(overall_assessment)} chars, "
|
|
"over the "
|
|
f"{_QUALITY_REPORT_OVERALL_ASSESSMENT_MAX_CHARS}-char cap"
|
|
),
|
|
remediate="shorten overall_assessment",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_quality_report(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
headline: str,
|
|
items: list[dict[str, Any]],
|
|
overall_assessment: str,
|
|
) -> Envelope:
|
|
"""Auditor files ONE Sentinel weekly "state of quality" report —
|
|
waiver-accumulation trends, conventions-violation hotspots, budget
|
|
anomalies — delivered as a held REPORT to the CEO.
|
|
|
|
Mirrors ``propose_market_brief``'s complete-at-propose asymmetry: a
|
|
report has no per-item CEO decision, so the exploration task
|
|
completes in this same call. Unlike ``propose_market_brief`` this is
|
|
deliberately NOT screened through
|
|
``injection_guard.screen_external_text`` — every input here is
|
|
internal org data (the findings ledger, the conventions table, the
|
|
spend tables, the Auditor's own read of the codebase), never
|
|
untrusted web/external text, so there is nothing to screen.
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _SENTINEL_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a quality report; only "
|
|
"the Auditor authors one"
|
|
),
|
|
remediate="this verb is Auditor-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_quality_report_fields(
|
|
headline, items, overall_assessment
|
|
):
|
|
return rej
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_sentinel_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_quality_report(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open sentinel exploration task assigned to you",
|
|
remediate=(
|
|
"propose_quality_report only runs against an active "
|
|
"exploration cycle spawned by the sentinel engine; wait "
|
|
"for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
await self._persist_quality_report(
|
|
task,
|
|
headline=headline,
|
|
items=items,
|
|
overall_assessment=overall_assessment,
|
|
)
|
|
await self._notify_quality_report(task, headline.strip())
|
|
return Envelope.ok(
|
|
status="quality_report_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reads the report in the panel",
|
|
context_briefing={
|
|
"headline": headline.strip(),
|
|
"item_count": len(items),
|
|
},
|
|
)
|
|
|
|
async def _persist_quality_report(
|
|
self,
|
|
task: Any,
|
|
*,
|
|
headline: str,
|
|
items: list[dict[str, Any]],
|
|
overall_assessment: str,
|
|
) -> None:
|
|
"""Normalize, persist, and complete — split out of
|
|
``propose_quality_report`` to keep its own cyclomatic complexity
|
|
under the xenon budget. Complete-at-propose: mirrors
|
|
``_persist_market_brief``."""
|
|
normalized_items = [
|
|
_normalize_quality_report_item(idx, raw) for idx, raw in enumerate(items)
|
|
]
|
|
markers.set_quality_report(
|
|
task,
|
|
{
|
|
"headline": headline.strip(),
|
|
"items": normalized_items,
|
|
"overall_assessment": overall_assessment.strip(),
|
|
},
|
|
)
|
|
task.status = TaskStatus.COMPLETED
|
|
await self.task.session.flush()
|
|
|
|
async def _notify_quality_report(self, task: Any, headline: str) -> None:
|
|
"""Best-effort CEO nudge the moment a quality report lands — ONE call
|
|
per cycle (a report, not N queue items), mirrors
|
|
``_notify_periscope_brief``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_sentinel_report(
|
|
task=task, task_id=task.id, headline=headline
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"sentinel telegram notify failed (best-effort)", error=str(exc)
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_campaign_name(cls, campaign_name: str) -> Envelope | None:
|
|
if rej := cls._reject_soup(campaign_name, field="campaign_name", min_chars=3):
|
|
return rej
|
|
if len(campaign_name) > _CAMPAIGN_NAME_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"campaign_name is {len(campaign_name)} chars, over the "
|
|
f"{_CAMPAIGN_NAME_MAX_CHARS}-char cap"
|
|
),
|
|
remediate="shorten campaign_name",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_campaign_post_body(
|
|
cls, raw: dict[str, Any], idx: int
|
|
) -> Envelope | None:
|
|
"""Split out of ``_reject_campaign_post`` to keep its own
|
|
return-statement count under the xenon/PLR0911 budget."""
|
|
body = raw.get("body")
|
|
if not isinstance(body, str) or not body.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"post {idx} is missing 'body'",
|
|
remediate=f"provide the tweet text for post {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(body, field=f"post {idx} body", min_chars=8):
|
|
return rej
|
|
if len(body) > MAX_TWEET_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"post {idx} body is {len(body)} chars, over the "
|
|
f"{MAX_TWEET_CHARS}-char tweet limit"
|
|
),
|
|
remediate=(
|
|
f"shorten post {idx} to {MAX_TWEET_CHARS} characters or fewer"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_campaign_post_stage(raw: dict[str, Any], idx: int) -> Envelope | None:
|
|
stage = raw.get("stage_label")
|
|
if not isinstance(stage, str) or stage.strip() not in _CAMPAIGN_STAGE_LABELS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"post {idx} stage_label must be one of "
|
|
f"{sorted(_CAMPAIGN_STAGE_LABELS)}"
|
|
),
|
|
remediate=(
|
|
f"set post {idx}'s stage_label to one of "
|
|
f"{sorted(_CAMPAIGN_STAGE_LABELS)}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_campaign_post_timing(
|
|
raw: dict[str, Any], idx: int, previous: datetime | None
|
|
) -> tuple[Envelope | None, Any]:
|
|
"""Parse + validate one post's ``publish_after``: a real ISO 8601
|
|
datetime, strictly in the future at propose time, and strictly after
|
|
the previous item's (ascending order across the campaign — spec §4's
|
|
teaser -> launch -> follow-up -> spotlight arc). Returns
|
|
``(rejection, parsed)`` — a non-None rejection means ``parsed`` is
|
|
unusable; the caller threads the clean ``parsed`` value into the NEXT
|
|
item's ascending-order check."""
|
|
value = raw.get("publish_after")
|
|
if not isinstance(value, str) or not value.strip():
|
|
return (
|
|
Envelope.invalid_state(
|
|
message=f"post {idx} is missing 'publish_after'",
|
|
remediate=(
|
|
f"provide post {idx}'s recommended publish time as an "
|
|
"ISO 8601 datetime"
|
|
),
|
|
context_briefing={},
|
|
),
|
|
None,
|
|
)
|
|
try:
|
|
parsed = datetime.fromisoformat(value.strip())
|
|
except ValueError:
|
|
return (
|
|
Envelope.invalid_state(
|
|
message=(
|
|
f"post {idx} publish_after {value!r} is not a valid "
|
|
"ISO 8601 datetime"
|
|
),
|
|
remediate=(
|
|
f"provide post {idx}'s publish_after as an ISO 8601 "
|
|
"datetime, e.g. '2026-08-01T09:00:00+00:00'"
|
|
),
|
|
context_briefing={},
|
|
),
|
|
None,
|
|
)
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=UTC)
|
|
if parsed <= datetime.now(UTC):
|
|
return (
|
|
Envelope.invalid_state(
|
|
message=f"post {idx} publish_after {value!r} is not in the future",
|
|
remediate=f"post {idx}'s publish_after must be a future timestamp",
|
|
context_briefing={},
|
|
),
|
|
None,
|
|
)
|
|
if previous is not None and parsed <= previous:
|
|
return (
|
|
Envelope.invalid_state(
|
|
message=(
|
|
f"post {idx} publish_after must be strictly after post "
|
|
f"{idx - 1}'s — campaign posts run in ascending order"
|
|
),
|
|
remediate=(
|
|
f"push post {idx}'s publish_after later than post {idx - 1}'s"
|
|
),
|
|
context_briefing={},
|
|
),
|
|
None,
|
|
)
|
|
return None, parsed
|
|
|
|
@classmethod
|
|
def _reject_campaign_post(
|
|
cls, raw: Any, idx: int, previous: datetime | None
|
|
) -> tuple[Envelope | None, Any]:
|
|
"""Validate one raw campaign-post dict; ``(None, parsed_publish_after)``
|
|
when clean."""
|
|
if not isinstance(raw, dict):
|
|
return (
|
|
Envelope.invalid_state(
|
|
message=f"post {idx} is not an object",
|
|
remediate=(
|
|
"each post must be an object with body/publish_after/"
|
|
"stage_label"
|
|
),
|
|
context_briefing={},
|
|
),
|
|
None,
|
|
)
|
|
if rej := cls._reject_campaign_post_body(raw, idx):
|
|
return rej, None
|
|
if rej := cls._reject_campaign_post_stage(raw, idx):
|
|
return rej, None
|
|
return cls._reject_campaign_post_timing(raw, idx, previous)
|
|
|
|
async def propose_campaign(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
campaign_name: str,
|
|
posts: list[dict[str, Any]],
|
|
) -> Envelope:
|
|
"""Head of Marketing authors ONE War Room campaign — an ordered set
|
|
of 2-6 held X drafts (teaser -> launch -> follow-up -> spotlight),
|
|
each carrying a recommended ``publish_after`` timestamp.
|
|
|
|
V1 is manual-cadence (spec, 2026-07-24, pinned by the orchestrating
|
|
session): ``publish_after`` is GUIDANCE rendered in the panel queue,
|
|
never a schedule anything acts on — the CEO approves each draft at
|
|
its own moment, exactly like every other X-queue draft ("nothing
|
|
auto-posts" stays absolute; see ``WarRoomEngine``'s module docstring
|
|
for the documented auto-schedule ceiling, not built here). Call this
|
|
exactly once per cycle: it materializes every post (via
|
|
``XEngine.materialize_campaign_post``) and completes the exploration
|
|
task in the same call — mirrors ``propose_market_brief``'s
|
|
complete-at-propose shape, batched over N posts.
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _WAR_ROOM_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a campaign; only the "
|
|
"Head of Marketing authors one"
|
|
),
|
|
remediate="this verb is Head-of-Marketing-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_campaign_name(campaign_name):
|
|
return rej
|
|
if not (_CAMPAIGN_MIN_POSTS <= len(posts) <= _CAMPAIGN_MAX_POSTS):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"a campaign needs {_CAMPAIGN_MIN_POSTS}-"
|
|
f"{_CAMPAIGN_MAX_POSTS} ordered posts, got {len(posts)}"
|
|
),
|
|
remediate=(
|
|
f"propose between {_CAMPAIGN_MIN_POSTS} and "
|
|
f"{_CAMPAIGN_MAX_POSTS} posts"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
name = campaign_name.strip()
|
|
normalized: list[dict[str, Any]] = []
|
|
previous: datetime | None = None
|
|
for idx, raw in enumerate(posts):
|
|
rejection, parsed = self._reject_campaign_post(raw, idx, previous)
|
|
if rejection is not None:
|
|
return rejection
|
|
previous = parsed
|
|
normalized.append(
|
|
{
|
|
"body": str(raw["body"]).strip(),
|
|
"campaign_name": name,
|
|
"stage_label": str(raw["stage_label"]).strip(),
|
|
"publish_after": parsed.isoformat(),
|
|
"sequence": idx + 1,
|
|
}
|
|
)
|
|
|
|
from roboco.services.task import get_task_service
|
|
from roboco.services.x_engine import get_x_engine
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_war_room_cycles()
|
|
task = next((t for t in cycles if t.assigned_to == agent_id), None)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open war-room exploration task assigned to you",
|
|
remediate=(
|
|
"propose_campaign only runs against an active exploration "
|
|
"cycle spawned by the War Room engine; wait for the next "
|
|
"cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
engine = get_x_engine(self.task.session)
|
|
for item in normalized:
|
|
await engine.materialize_campaign_post(
|
|
exploration_task=task,
|
|
campaign_ref={
|
|
"campaign_name": item["campaign_name"],
|
|
"stage_label": item["stage_label"],
|
|
"publish_after": item["publish_after"],
|
|
"sequence": item["sequence"],
|
|
},
|
|
body=item["body"],
|
|
)
|
|
task.status = TaskStatus.COMPLETED
|
|
await self.task.session.flush()
|
|
return Envelope.ok(
|
|
status="campaign_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO reviews each post in the X post queue",
|
|
context_briefing={"campaign_name": name, "post_count": len(normalized)},
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_caption(
|
|
cls, value: str, *, field: str, max_chars: int
|
|
) -> Envelope | None:
|
|
"""Soup + max-length check for one caption field, folded into a single
|
|
return point so ``_reject_video_fields`` (which calls this twice) stays
|
|
under the xenon/PLR0911 return-count budget."""
|
|
if rej := cls._reject_soup(value, field=field, min_chars=8):
|
|
return rej
|
|
if len(value) > max_chars:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"{field} is {len(value)} chars, over the {max_chars}-char limit"
|
|
),
|
|
remediate=f"shorten {field} to {max_chars} characters or fewer",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_video_fields(
|
|
cls,
|
|
composition_id: str,
|
|
x_caption: str,
|
|
tiktok_caption: str,
|
|
platforms: list[str],
|
|
) -> Envelope | None:
|
|
"""Soup + limit + platform-set validation for a video draft's fields,
|
|
collapsed into one caller-side check (keeps propose_video's return-
|
|
statement count under the xenon/PLR0911 budget)."""
|
|
if rej := cls._reject_soup(composition_id, field="composition_id", min_chars=2):
|
|
return rej
|
|
# Mirror the video-renderer sidecar's charset rule so an unrenderable
|
|
# id is refused at authoring time, not at render time days later.
|
|
if not _COMPOSITION_ID_RE.fullmatch(composition_id.strip()):
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"composition_id {composition_id!r} is not renderable — "
|
|
"letters, digits, '_' or '-' with optional interior dots"
|
|
),
|
|
remediate=(
|
|
"rename the composition dir to match (e.g. "
|
|
"'release-0-25-0' or 'release-0.25.0') and call "
|
|
"propose_video again with that id"
|
|
),
|
|
)
|
|
if rej := cls._reject_caption(
|
|
x_caption, field="x_caption", max_chars=MAX_TWEET_CHARS
|
|
):
|
|
return rej
|
|
if rej := cls._reject_caption(
|
|
tiktok_caption, field="tiktok_caption", max_chars=_MAX_TIKTOK_CAPTION_CHARS
|
|
):
|
|
return rej
|
|
if not platforms or not set(platforms) <= _VIDEO_PLATFORMS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"platforms {platforms!r} must be a non-empty subset of "
|
|
f"{sorted(_VIDEO_PLATFORMS)}"
|
|
),
|
|
remediate="pass platforms as a non-empty list from {'x','tiktok'}",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def propose_video(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
composition_id: str,
|
|
x_caption: str,
|
|
tiktok_caption: str,
|
|
platforms: list[str],
|
|
input_props: dict[str, Any] | None = None,
|
|
) -> Envelope:
|
|
"""UX/UI dev proposes a video's composition ref + captions.
|
|
|
|
Metadata-only — NO render, no sidecar/HTTP call: rendering happens
|
|
later in an orchestrator-async loop, off this path (the do-tool
|
|
transport has a fixed 30s timeout a real render would blow through).
|
|
|
|
Gated on the caller's TEAM, not role (v1: UX/UI only) — every dev's
|
|
manifest carries this tool, so the runtime check here is the real
|
|
gate. Validates the caption limits + platform set, then MERGES the
|
|
fields onto the caller's open authoring task's ``video_draft``
|
|
marker, preserving the occasion/script/brief the video engine seeded
|
|
it with. commit + open_pr afterward sends the composition through
|
|
the normal PR-review gate.
|
|
"""
|
|
from roboco.foundation.identity import Team
|
|
|
|
team = await self._caller_team(agent_id)
|
|
if team is not Team.UX_UI:
|
|
team_label = team.value if team is not None else "none"
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"team {team_label!r} cannot propose video metadata; only "
|
|
"UX/UI authors video compositions"
|
|
),
|
|
remediate="this verb is UX/UI-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_video_fields(
|
|
composition_id, x_caption, tiktok_caption, platforms
|
|
):
|
|
return rej
|
|
|
|
from roboco.services.task import VIDEO_SOURCE, get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
task = await task_svc.get_active_task_for_agent(agent_id)
|
|
if task is None or task.source != VIDEO_SOURCE:
|
|
return Envelope.invalid_state(
|
|
message="no active video-authoring task assigned to you",
|
|
remediate=(
|
|
"propose_video runs against the video task you're actively "
|
|
"working on; claim your assigned authoring task first"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
existing = markers.get_video_draft(task) or {}
|
|
markers.set_video_draft(
|
|
task,
|
|
{
|
|
**existing,
|
|
"composition_id": composition_id,
|
|
"input_props": input_props or {},
|
|
"x_caption": x_caption,
|
|
"tiktok_caption": tiktok_caption,
|
|
"platforms": platforms,
|
|
},
|
|
)
|
|
await self.task.session.flush()
|
|
return Envelope.ok(
|
|
status="video_proposed",
|
|
task_id=str(task.id),
|
|
next="commit your composition, then open_pr to send it through the PR gate",
|
|
context_briefing={
|
|
"composition_id": composition_id,
|
|
"platforms": platforms,
|
|
},
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_postmortem_text_fields(
|
|
cls, incident_summary: str, root_cause: str
|
|
) -> Envelope | None:
|
|
"""Soup + length caps on the postmortem's two free-text narrative
|
|
fields, folded into one caller-side check (xenon/PLR0911 budget —
|
|
mirrors ``_reject_feature_spotlight_fields``)."""
|
|
if rej := cls._reject_soup(
|
|
incident_summary, field="incident_summary", min_chars=20
|
|
):
|
|
return rej
|
|
if len(incident_summary) > _CORONER_INCIDENT_SUMMARY_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"incident_summary is {len(incident_summary)} chars, over "
|
|
f"the {_CORONER_INCIDENT_SUMMARY_MAX_CHARS}-char limit"
|
|
),
|
|
remediate="shorten incident_summary",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(root_cause, field="root_cause", min_chars=20):
|
|
return rej
|
|
if len(root_cause) > _CORONER_ROOT_CAUSE_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"root_cause is {len(root_cause)} chars, over the "
|
|
f"{_CORONER_ROOT_CAUSE_MAX_CHARS}-char limit"
|
|
),
|
|
remediate="shorten root_cause",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_postmortem_failed_stage(failed_stage: str) -> Envelope | None:
|
|
"""``failed_stage`` must be a real lifecycle status — never a made-up
|
|
label — so it stays comparable across postmortems."""
|
|
from roboco.models.base import TaskStatus
|
|
|
|
valid = {s.value for s in TaskStatus}
|
|
if failed_stage not in valid:
|
|
return Envelope.invalid_state(
|
|
message=f"failed_stage {failed_stage!r} is not a real task status",
|
|
remediate=f"pass one of: {sorted(valid)}",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_postmortem_process_change(cls, process_change: Any) -> Envelope | None:
|
|
if not isinstance(process_change, dict):
|
|
return Envelope.invalid_state(
|
|
message="process_change must be an object",
|
|
remediate="pass process_change={'kind': ..., 'description': ...}",
|
|
context_briefing={},
|
|
)
|
|
kind = process_change.get("kind")
|
|
if kind not in _CORONER_PROCESS_CHANGE_KINDS:
|
|
return Envelope.invalid_state(
|
|
message=f"process_change.kind {kind!r} is invalid",
|
|
remediate=(
|
|
f"kind must be one of {sorted(_CORONER_PROCESS_CHANGE_KINDS)}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
description = str(process_change.get("description", ""))
|
|
if rej := cls._reject_soup(
|
|
description, field="process_change.description", min_chars=15
|
|
):
|
|
return rej
|
|
if len(description) > _CORONER_PROCESS_CHANGE_DESC_MAX_CHARS:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"process_change.description is {len(description)} chars, "
|
|
f"over the {_CORONER_PROCESS_CHANGE_DESC_MAX_CHARS}-char limit"
|
|
),
|
|
remediate="shorten process_change.description",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_postmortem_playbook(
|
|
cls, process_change: dict[str, Any], playbook: dict[str, Any] | None
|
|
) -> Envelope | None:
|
|
"""``playbook`` is required iff ``process_change.kind == 'playbook'``
|
|
(spec §4) — never optional-but-ignored on that kind, never demanded
|
|
on any other."""
|
|
if process_change.get("kind") != "playbook":
|
|
return None
|
|
if not isinstance(playbook, dict):
|
|
return Envelope.invalid_state(
|
|
message="process_change.kind='playbook' requires a playbook",
|
|
remediate="pass playbook={'title': ..., 'body': ...}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
str(playbook.get("title", "")), field="playbook.title", min_chars=5
|
|
):
|
|
return rej
|
|
return cls._reject_soup(
|
|
str(playbook.get("body", "")), field="playbook.body", min_chars=20
|
|
)
|
|
|
|
@classmethod
|
|
def _reject_postmortem(
|
|
cls,
|
|
incident_summary: str,
|
|
root_cause: str,
|
|
failed_stage: str,
|
|
process_change: Any,
|
|
playbook: dict[str, Any] | None,
|
|
) -> Envelope | None:
|
|
if rej := cls._reject_postmortem_text_fields(incident_summary, root_cause):
|
|
return rej
|
|
if rej := cls._reject_postmortem_failed_stage(failed_stage):
|
|
return rej
|
|
if rej := cls._reject_postmortem_process_change(process_change):
|
|
return rej
|
|
return cls._reject_postmortem_playbook(process_change, playbook)
|
|
|
|
async def _draft_coroner_playbook(
|
|
self, agent_id: UUID, incident_summary: str, playbook: dict[str, Any]
|
|
) -> tuple[str | None, Envelope | None]:
|
|
"""Create the process-change playbook DRAFT directly via
|
|
``PlaybookService`` — not the ``draft_playbook`` do-verb, since this
|
|
call already runs inside the Auditor-only ``propose_postmortem`` gate.
|
|
The Auditor also carries ``draft_playbook`` on its manifest
|
|
(role_config.py) so a coroner-authored draft is indistinguishable
|
|
from any other in the pending-playbook curation queue — reviewed and
|
|
approved/rejected there same as any delivery-role draft, never
|
|
self-approved in this same call. Returns (playbook_id, None) on
|
|
success or (None, rejection_envelope) on a title conflict — checked
|
|
BEFORE any postmortem-task mutation so a conflict is a clean,
|
|
retryable rejection, not a half-completed autopsy."""
|
|
from roboco.models.playbook import PlaybookCreate
|
|
from roboco.services.base import ConflictError
|
|
from roboco.services.playbook import get_playbook_service
|
|
|
|
try:
|
|
drafted = await get_playbook_service(self.task.session).draft(
|
|
PlaybookCreate(
|
|
title=str(playbook["title"]).strip(),
|
|
problem=incident_summary.strip(),
|
|
procedure=str(playbook["body"]).strip(),
|
|
tags=["coroner", "postmortem"],
|
|
),
|
|
created_by=agent_id,
|
|
)
|
|
except ConflictError as exc:
|
|
return None, Envelope.invalid_state(
|
|
message=str(exc),
|
|
remediate="use a more distinct playbook title (slug must be unique)",
|
|
context_briefing={},
|
|
)
|
|
return str(drafted.id), None
|
|
|
|
@classmethod
|
|
def _reject_playbook_draft_item(cls, raw: Any, idx: int) -> Envelope | None:
|
|
"""Validate one raw playbook-draft dict; None when clean. Mirrors
|
|
``_reject_quality_report_item_text_fields``'s loop-over-fields shape."""
|
|
if not isinstance(raw, dict):
|
|
return Envelope.invalid_state(
|
|
message=f"draft {idx} is not an object",
|
|
remediate="each draft needs title/body/pattern_evidence",
|
|
context_briefing={},
|
|
)
|
|
for field, min_chars, max_chars in (
|
|
("title", 5, _PLAYBOOK_DRAFT_TITLE_MAX_CHARS),
|
|
("body", 20, _PLAYBOOK_DRAFT_BODY_MAX_CHARS),
|
|
("pattern_evidence", 15, _PLAYBOOK_DRAFT_PATTERN_EVIDENCE_MAX_CHARS),
|
|
):
|
|
value = raw.get(field)
|
|
if not isinstance(value, str) or not value.strip():
|
|
return Envelope.invalid_state(
|
|
message=f"draft {idx} is missing '{field}'",
|
|
remediate=f"provide a substantive '{field}' for draft {idx}",
|
|
context_briefing={},
|
|
)
|
|
if rej := cls._reject_soup(
|
|
value, field=f"draft {idx} {field}", min_chars=min_chars
|
|
):
|
|
return rej
|
|
if len(value) > max_chars:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"draft {idx} {field} is {len(value)} chars, over the "
|
|
f"{max_chars}-char cap"
|
|
),
|
|
remediate=f"shorten draft {idx}'s {field}",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _reject_playbook_draft_duplicate_titles(
|
|
drafts: list[dict[str, Any]],
|
|
) -> Envelope | None:
|
|
"""Case-insensitive dedup WITHIN this batch — split out so the
|
|
title-conflict message is distinct from the per-item field check."""
|
|
seen: set[str] = set()
|
|
for idx, raw in enumerate(drafts):
|
|
title = str(raw.get("title", "")).strip().lower()
|
|
if title in seen:
|
|
return Envelope.invalid_state(
|
|
message=f"draft {idx} title duplicates another draft in this batch",
|
|
remediate="give each draft a distinct title",
|
|
context_briefing={},
|
|
)
|
|
seen.add(title)
|
|
return None
|
|
|
|
@classmethod
|
|
def _reject_playbook_drafts_batch(
|
|
cls, drafts: list[dict[str, Any]]
|
|
) -> Envelope | None:
|
|
"""The count cap + per-draft validation + in-batch dedup, split out
|
|
of ``propose_playbook_drafts`` to keep its own return-statement
|
|
count under the xenon/PLR0911 budget."""
|
|
from roboco.foundation.policy.board_programs import PROGRAMS
|
|
|
|
max_drafts = PROGRAMS["librarian"].max_items_per_cycle
|
|
if not (1 <= len(drafts) <= max_drafts):
|
|
return Envelope.invalid_state(
|
|
message=f"propose 1-{max_drafts} playbook drafts, got {len(drafts)}",
|
|
remediate=f"propose between 1 and {max_drafts} drafts",
|
|
context_briefing={},
|
|
)
|
|
for idx, raw in enumerate(drafts):
|
|
if rej := cls._reject_playbook_draft_item(raw, idx):
|
|
return rej
|
|
return cls._reject_playbook_draft_duplicate_titles(drafts)
|
|
|
|
async def _reject_playbook_drafts_existing_titles(
|
|
self, drafts: list[dict[str, Any]]
|
|
) -> Envelope | None:
|
|
"""Live, unbounded case-insensitive dedup against every non-archived
|
|
playbook already in the store — the mining prompt only shows the
|
|
most-recent 20 as a hint, so this re-checks fresh at propose time
|
|
rather than trusting what the Auditor read minutes ago."""
|
|
from roboco.services.librarian_engine import get_librarian_engine
|
|
|
|
existing = await get_librarian_engine(
|
|
self.task.session
|
|
).existing_playbook_titles_lower()
|
|
for idx, raw in enumerate(drafts):
|
|
title = str(raw["title"]).strip()
|
|
if title.lower() in existing:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"draft {idx} title {title!r} duplicates an existing playbook"
|
|
),
|
|
remediate=(
|
|
"give this draft a distinct title, or drop it — a playbook "
|
|
"for this pattern may already exist"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def _draft_librarian_playbooks(
|
|
self, agent_id: UUID, drafts: list[dict[str, Any]]
|
|
) -> tuple[list[dict[str, str]], Envelope | None]:
|
|
"""Create each validated draft as a real DRAFT playbook via
|
|
``PlaybookService.draft()`` directly — the Coroner precedent
|
|
(``_draft_coroner_playbook`` above), never the ``draft_playbook``
|
|
do-verb, so "auditor curates but does not draft" stays true at the
|
|
do-verb surface even though the Auditor originates these drafts.
|
|
|
|
A per-item ``ConflictError`` (a genuine same-tick race — the live
|
|
pre-check in ``_reject_playbook_drafts_existing_titles`` already
|
|
closed the realistic window) aborts the rest of the batch with a
|
|
clean rejection.
|
|
ponytail: no rollback of earlier successes in this loop — any draft
|
|
already created before the conflict stays a real, independently
|
|
valid playbook riding the normal curation queue (just not
|
|
cross-referenced on this particular report); this is the same
|
|
residual race Coroner already accepts, and Librarian's own
|
|
single-open-cycle dedup makes a same-cycle collision vanishingly
|
|
rare in practice.
|
|
"""
|
|
from roboco.models.playbook import PlaybookCreate
|
|
from roboco.services.base import ConflictError
|
|
from roboco.services.playbook import get_playbook_service
|
|
|
|
svc = get_playbook_service(self.task.session)
|
|
created: list[dict[str, str]] = []
|
|
for idx, raw in enumerate(drafts):
|
|
try:
|
|
drafted = await svc.draft(
|
|
PlaybookCreate(
|
|
title=str(raw["title"]).strip(),
|
|
problem=str(raw["pattern_evidence"]).strip(),
|
|
procedure=str(raw["body"]).strip(),
|
|
tags=["librarian", "auto-authored"],
|
|
),
|
|
created_by=agent_id,
|
|
)
|
|
except ConflictError as exc:
|
|
return created, Envelope.invalid_state(
|
|
message=f"draft {idx}: {exc}",
|
|
remediate="use a more distinct title (slug must be unique)",
|
|
context_briefing={"drafted_before_conflict": len(created)},
|
|
)
|
|
created.append({"id": str(drafted.id), "title": drafted.title})
|
|
return created, None
|
|
|
|
async def propose_playbook_drafts(
|
|
self, *, agent_id: UUID, drafts: list[dict[str, Any]]
|
|
) -> Envelope:
|
|
"""Auditor mines journals/learnings for repeated patterns and drafts
|
|
1-3 playbooks on its open Librarian cycle task, completing it in the
|
|
same call (mirrors ``propose_market_brief``/``propose_quality_report``'s
|
|
complete-at-propose asymmetry — a mining cycle has no per-item CEO
|
|
decision to wait on).
|
|
|
|
Each draft is created via ``PlaybookService.draft()`` DIRECTLY — the
|
|
same Coroner precedent ``_draft_coroner_playbook`` established: the
|
|
Auditor does NOT also carry ``draft_playbook`` on its manifest
|
|
(``role_config.py``, ``test_playbook_verbs.py``'s "auditor curates
|
|
but does not draft" invariant), so a Librarian-authored draft reaches
|
|
the pending-playbook curation queue through this direct service
|
|
call, never the do-verb every delivery role uses. A LATER Auditor
|
|
spawn curates them — a deliberate, documented self-curation
|
|
asymmetry (see ``agents/prompts/identities/auditor.md``).
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _LIBRARIAN_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose playbook drafts; only the "
|
|
"Auditor mines for patterns"
|
|
),
|
|
remediate="this verb is Auditor-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_playbook_drafts_batch(drafts):
|
|
return rej
|
|
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_librarian_cycles()
|
|
task = next(
|
|
(
|
|
t
|
|
for t in cycles
|
|
if t.assigned_to == agent_id and markers.get_playbook_drafts(t) is None
|
|
),
|
|
None,
|
|
)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open Librarian mining task assigned to you",
|
|
remediate=(
|
|
"propose_playbook_drafts only runs against an active mining "
|
|
"cycle spawned by the librarian engine; wait for the next cycle"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
if rej := await self._reject_playbook_drafts_existing_titles(drafts):
|
|
return rej
|
|
|
|
created, rej = await self._draft_librarian_playbooks(agent_id, drafts)
|
|
if rej is not None:
|
|
return rej
|
|
|
|
markers.set_playbook_drafts(task, {"drafts": created})
|
|
task.status = TaskStatus.COMPLETED
|
|
await self.task.session.flush()
|
|
await self._notify_librarian_drafts(task, created)
|
|
return Envelope.ok(
|
|
status="playbook_drafts_proposed",
|
|
task_id=str(task.id),
|
|
next=(
|
|
"i_am_idle() — the drafts ride the normal pending-playbook "
|
|
"curation queue"
|
|
),
|
|
context_briefing={"draft_count": len(created)},
|
|
)
|
|
|
|
async def _notify_librarian_drafts(
|
|
self, task: Any, created: list[dict[str, str]]
|
|
) -> None:
|
|
"""Best-effort CEO nudge the moment Librarian mines its drafts —
|
|
mirrors ``_notify_postmortem``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_librarian_drafts(
|
|
task=task,
|
|
task_id=task.id,
|
|
titles=[d["title"] for d in created],
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"librarian telegram notify failed (best-effort)", error=str(exc)
|
|
)
|
|
|
|
async def propose_postmortem(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
incident_summary: str,
|
|
root_cause: str,
|
|
failed_stage: str,
|
|
process_change: dict[str, Any],
|
|
playbook: dict[str, Any] | None = None,
|
|
) -> Envelope:
|
|
"""Auditor authors ONE Coroner postmortem on its open autopsy task,
|
|
completing it in the same call — no per-item CEO queue (spec §4:
|
|
a report, not a list of items the CEO decides one by one). Call
|
|
exactly once per autopsy cycle.
|
|
"""
|
|
role = await self._caller_role(agent_id)
|
|
if role not in _CORONER_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role!r} cannot propose a postmortem; only the "
|
|
"Auditor authors one"
|
|
),
|
|
remediate="this verb is Auditor-only",
|
|
context_briefing={},
|
|
)
|
|
if rej := self._reject_postmortem(
|
|
incident_summary, root_cause, failed_stage, process_change, playbook
|
|
):
|
|
return rej
|
|
|
|
from roboco.services.coroner_engine import get_coroner_engine
|
|
from roboco.services.task import get_task_service
|
|
|
|
task_svc = get_task_service(self.task.session)
|
|
cycles = await task_svc.list_open_coroner_cycles()
|
|
task = next((t for t in cycles if t.assigned_to == agent_id), None)
|
|
if task is None:
|
|
return Envelope.invalid_state(
|
|
message="no open Coroner autopsy task assigned to you",
|
|
remediate=(
|
|
"propose_postmortem only runs against an active autopsy "
|
|
"spawned by the coroner engine; wait for the next incident"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
playbook_id: str | None = None
|
|
if process_change["kind"] == "playbook":
|
|
playbook_id, rej = await self._draft_coroner_playbook(
|
|
agent_id, incident_summary, playbook or {}
|
|
)
|
|
if rej is not None:
|
|
return rej
|
|
|
|
engine = get_coroner_engine(self.task.session)
|
|
await engine.complete_with_postmortem(
|
|
task,
|
|
{
|
|
"incident_summary": incident_summary.strip(),
|
|
"root_cause": root_cause.strip(),
|
|
"failed_stage": failed_stage,
|
|
"process_change": {
|
|
"kind": process_change["kind"],
|
|
"description": str(process_change["description"]).strip(),
|
|
# A "playbook" kind already routed straight into the
|
|
# playbook curation queue above — nothing left for the
|
|
# CEO to decide on THIS process change, so it never
|
|
# enters the proposed/approved/rejected per-item flow
|
|
# (CoronerService.approve_process_change/
|
|
# reject_process_change refuse it outright).
|
|
"status": (
|
|
"not_applicable"
|
|
if process_change["kind"] == "playbook"
|
|
else "proposed"
|
|
),
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
},
|
|
"playbook_id": playbook_id,
|
|
},
|
|
)
|
|
await self._notify_postmortem(task, incident_summary, process_change["kind"])
|
|
return Envelope.ok(
|
|
status="postmortem_proposed",
|
|
task_id=str(task.id),
|
|
next="i_am_idle() — the CEO is notified; no per-item decision needed",
|
|
context_briefing={
|
|
"failed_stage": failed_stage,
|
|
"process_change_kind": process_change["kind"],
|
|
"playbook_id": playbook_id,
|
|
},
|
|
)
|
|
|
|
async def _notify_postmortem(
|
|
self, task: Any, incident_summary: str, process_change_kind: str
|
|
) -> None:
|
|
"""Best-effort push notification the moment a postmortem lands —
|
|
mirrors ``_notify_pest_hunt_items``."""
|
|
if self._deps.notification_delivery is None:
|
|
return
|
|
try:
|
|
await self._deps.notification_delivery.notify_ceo_of_postmortem(
|
|
task=task,
|
|
task_id=task.id,
|
|
incident_summary=incident_summary,
|
|
process_change_kind=process_change_kind,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"coroner postmortem telegram notify failed (best-effort)",
|
|
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,
|
|
*,
|
|
agent_id: UUID,
|
|
recipient: str,
|
|
text: str,
|
|
task_id: UUID | None = None,
|
|
skill: str | None = None,
|
|
) -> Envelope:
|
|
"""A2A direct message. Requires task_id (active or explicit)."""
|
|
if rej := self._reject_soup(text, field="message", min_chars=2):
|
|
return rej
|
|
# Spec §5.5: no-comms roles — defense-in-depth runtime guard. dm() is
|
|
# the channel through which a no-comms role could "speak"; covers the
|
|
# human-only prompter/secretary (own dedicated chat pages, no agent
|
|
# A2A surface at all).
|
|
agent = await self.task.agent_for(agent_id)
|
|
caller_role = str(agent.role) if agent is not None else ""
|
|
if caller_role in _NO_COMMS_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role '{caller_role}' is a silent / no-comms role;"
|
|
" dm is not permitted"
|
|
),
|
|
remediate=(
|
|
"use note() to record; this human-only role has no"
|
|
" agent-comms surface"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
if task_id is not None:
|
|
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
|
|
return reject
|
|
else:
|
|
t = await self.task.get_journal_context_task_for_agent(agent_id)
|
|
if t is not None:
|
|
task_id = t.id
|
|
if task_id is None:
|
|
return Envelope.invalid_state(
|
|
message="dm requires a task_id (no active task and none provided)",
|
|
remediate="provide task_id explicitly or claim a task first",
|
|
context_briefing={},
|
|
)
|
|
# Catch A2A access denials and return an Envelope. If the
|
|
# error escapes here it's caught by FastAPI's middleware and
|
|
# rendered as RobocoError.to_dict() — a dict-shaped 'error'
|
|
# field that breaks do_server's circuit-breaker frozenset
|
|
# check (a TypeError: unhashable type: 'dict').
|
|
from roboco.enforcement.a2a_access import A2AAccessDeniedError
|
|
|
|
try:
|
|
await self.a2a.send(
|
|
from_agent=agent_id,
|
|
to_agent=recipient,
|
|
task_id=task_id,
|
|
body=text,
|
|
skill=skill,
|
|
)
|
|
except A2AAccessDeniedError as e:
|
|
remediate = e.route_hint or e.reason
|
|
return Envelope.not_authorized(
|
|
message=e.message,
|
|
remediate=remediate,
|
|
context_briefing={},
|
|
)
|
|
return Envelope.ok(
|
|
status="sent",
|
|
task_id=str(task_id),
|
|
next="continue",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def notify(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
target: str,
|
|
text: str,
|
|
priority: str = "normal",
|
|
task_id: UUID | None = None,
|
|
) -> Envelope:
|
|
"""Send a formal ack-required notification (PMs and Board only).
|
|
|
|
Distinct from `dm` (informal A2A, no ack):
|
|
a notification is a formal signal that the recipient must
|
|
acknowledge. Pre-gateway, NotificationService restricted senders
|
|
to PMs/Board; the gateway re-asserts that gate here because the
|
|
do.py router is shared by all roles (no router-level dep).
|
|
|
|
``target`` is an agent slug ("be-dev-1", "main-pm", "ceo");
|
|
NotificationService resolves it to a UUID at insert time.
|
|
``priority`` is one of normal|high|urgent. ``task_id`` is
|
|
auto-filled from the caller's active task when omitted, but
|
|
omission is permitted for off-task notifications (e.g., Board
|
|
broadcasts).
|
|
"""
|
|
from roboco.models import NotificationPriority
|
|
|
|
if rej := self._reject_soup(text, field="notification", min_chars=5):
|
|
return rej
|
|
if priority not in _VALID_NOTIFY_PRIORITIES:
|
|
return Envelope.invalid_state(
|
|
message=f"invalid priority {priority!r}",
|
|
remediate=(
|
|
f"priority must be one of: {sorted(_VALID_NOTIFY_PRIORITIES)}"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
agent = await self.task.agent_for(agent_id)
|
|
caller_role = str(agent.role) if agent is not None else ""
|
|
if caller_role not in _NOTIFY_ALLOWED_ROLES:
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {caller_role!r} cannot send formal notifications; "
|
|
"only PMs and Board may issue ack-required signals"
|
|
),
|
|
remediate=(
|
|
"use dm() for informal A2A. "
|
|
"notify() is reserved for cell_pm, main_pm, "
|
|
"product_owner, and head_marketing."
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if task_id is not None:
|
|
if reject := await self._verify_explicit_task_ownership(agent_id, task_id):
|
|
return reject
|
|
else:
|
|
t = await self.task.get_journal_context_task_for_agent(agent_id)
|
|
if t is not None:
|
|
task_id = t.id
|
|
# A dependency block is a "wait silently" situation — never a CEO signal.
|
|
# An agent must not page the CEO to relax or escalate a task that is
|
|
# simply waiting on an unfinished upstream; that wait clears on its own.
|
|
# Also reject human-only recipients (prompter/secretary) — they have no
|
|
# agent ack path, so an ack-required signal would sit permanently unacked
|
|
# and suppress later same-purpose notifications via the dedup query.
|
|
if reject := await self._reject_disallowed_recipient(target, task_id):
|
|
return reject
|
|
await self.notifications.send_ack_notification(
|
|
from_agent=agent_id,
|
|
to_agent=target,
|
|
body=text,
|
|
priority=NotificationPriority(priority),
|
|
task_id=task_id,
|
|
)
|
|
return Envelope.ok(
|
|
status="sent",
|
|
task_id=str(task_id) if task_id else None,
|
|
next="continue",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _reject_disallowed_recipient(
|
|
self, target: str, task_id: UUID | None
|
|
) -> Envelope | None:
|
|
"""Rejection envelope for a notify() recipient the design disallows.
|
|
|
|
Two cases, checked in order:
|
|
1. F048 — a human-only recipient (prompter/secretary) with no agent ack
|
|
path. The knowledge-share path already excludes all three human-only
|
|
roles (learning.py); the general notify path did not, so an
|
|
ack-required ALERT could reach a human-driven role and sit permanently
|
|
unacked (polluting the panel's pending-ack view and, via the dedup
|
|
query's ``~acked_by.contains``, permanently suppressing any later
|
|
same-purpose notification from the same sender to that human role).
|
|
The CEO is human too but acks via the panel, so it is NOT rejected
|
|
here (its only disallowed case — a dependency-block page — is case 2).
|
|
2. A CEO notification about an open dependency block — pure noise; the
|
|
wait clears when the upstream completes.
|
|
"""
|
|
from roboco.agents_config import get_agent_role
|
|
|
|
recipient_role = get_agent_role(target)
|
|
if recipient_role in ("prompter", "secretary"):
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"cannot notify {target!r} — the {recipient_role} is a"
|
|
" human-only role with no agent ack path; an ack-required"
|
|
" signal would sit permanently unacked and suppress later"
|
|
" same-purpose notifications via the dedup query"
|
|
),
|
|
remediate=(
|
|
"escalate via the"
|
|
" CEO route. ack-required notify() targets must be agents"
|
|
" (or the CEO, who acks via the panel)"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return await self._reject_ceo_dependency_notify(target, task_id)
|
|
|
|
async def _reject_ceo_dependency_notify(
|
|
self, target: str, task_id: UUID | None
|
|
) -> Envelope | None:
|
|
"""Rejection envelope if this is a CEO notification about a dep block.
|
|
|
|
A dependency block clears when the upstream completes — paging the CEO
|
|
about it is pure noise and burn. Returns None when the notification is
|
|
allowed (non-CEO target, no task, or no open dependency).
|
|
"""
|
|
from roboco.agents_config import is_ceo
|
|
|
|
if task_id is None or not is_ceo(target):
|
|
return None
|
|
dep_block = await self._dependency_block_reason(task_id)
|
|
if not dep_block:
|
|
return None
|
|
return Envelope.invalid_state(
|
|
message=f"cannot notify the CEO about a dependency block — {dep_block}",
|
|
remediate=(
|
|
"a dependency block clears automatically when the upstream task "
|
|
"completes — do not notify or escalate. Call i_am_idle() and "
|
|
"wait; the task resumes on its own."
|
|
),
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _dependency_block_reason(self, task_id: UUID) -> str | None:
|
|
"""Reason string if ``task_id`` is waiting on an unfinished dependency.
|
|
|
|
Used to refuse CEO notifications about a dependency block: such a block
|
|
is resolved by the upstream completing, not by a human, so paging the
|
|
CEO is pure noise and burn.
|
|
"""
|
|
task = await self.task.get(task_id)
|
|
if task is None:
|
|
return None
|
|
dep_ids = list(task.dependency_ids or [])
|
|
if not dep_ids:
|
|
return None
|
|
unmet = await self.task.unmet_dependency_ids(dep_ids)
|
|
if unmet:
|
|
noun = "dependency" if len(unmet) == 1 else "dependencies"
|
|
return f"{len(unmet)} {noun} not yet completed"
|
|
return None
|
|
|
|
async def _is_caller_dependency(self, agent_id: UUID, task: Any) -> bool:
|
|
"""True when ``task`` is a dependency of a task the caller is assigned to.
|
|
|
|
A dependent agent (e.g. a frontend cell waiting on a UX design task)
|
|
must be able to inspect what it is blocked on; read-only evidence is the
|
|
right tool, and the strict cross-agent ownership gate would otherwise
|
|
reject it.
|
|
"""
|
|
assigned = await self.task.list_assigned_for_agent(agent_id)
|
|
return any(task.id in (a.dependency_ids or []) for a in assigned)
|
|
|
|
async def evidence(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
task_id: UUID,
|
|
) -> Envelope:
|
|
"""Inspect a task's PR diff, commits, files.
|
|
|
|
Fetches the dev branch into the agent's workspace before diffing.
|
|
Allows inspection when the caller is the assignee, the task is
|
|
unassigned, the caller co-reviews a shared board task, or the task is a
|
|
dependency the caller is waiting on — strict ownership only blocks
|
|
snooping an unrelated, actively-owned task.
|
|
|
|
``files_changed`` and ``pr_diff_summary`` are pulled from git (against
|
|
the branch's parent — the authoritative source) rather than the latest
|
|
commit's delta, so reviewers see the full multi-commit change set.
|
|
|
|
The git diff/fetch segment and the DB-reads segment run concurrently
|
|
via ``asyncio.gather``, and the whole evidence-assembly is wrapped in
|
|
a bounded ``evidence_assembly_timeout_seconds`` timeout that returns a
|
|
structured ``gateway_timeout`` envelope on trip instead of hitting the
|
|
outer 120s server-side rollback.
|
|
"""
|
|
t = await self.task.get(task_id)
|
|
if t is None:
|
|
return Envelope.not_found(message=f"task {task_id} not found")
|
|
# Reads are allowed for the assignee, an unassigned task, a board
|
|
# co-reviewer of a shared coordination task, OR a caller whose own work
|
|
# depends on this task. Strict ownership only blocks snooping an
|
|
# unrelated, actively-owned task.
|
|
if (
|
|
t.assigned_to is not None
|
|
and t.assigned_to != agent_id
|
|
and not await self._board_may_co_review(agent_id, t)
|
|
and not await self._is_caller_dependency(agent_id, t)
|
|
):
|
|
return _ownership_violation(task_id)
|
|
if t.branch_name and t.work_session_id:
|
|
await self.workspace.fetch_branch_for_inspection(
|
|
agent_id=agent_id, branch_name=t.branch_name
|
|
)
|
|
timeout = settings.evidence_assembly_timeout_seconds
|
|
try:
|
|
(
|
|
(diff, files_changed),
|
|
(
|
|
journal_highlights,
|
|
parent_context,
|
|
open_findings,
|
|
),
|
|
) = await asyncio.wait_for(
|
|
asyncio.gather(
|
|
self._evidence_git_and_fetch(t, agent_id),
|
|
self._evidence_db_reads(task_id),
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
except TimeoutError:
|
|
return Envelope.gateway_timeout(
|
|
component="git diff/fetch or a journal/ancestor/findings DB read",
|
|
timeout_seconds=timeout,
|
|
remediate=(
|
|
"retry evidence(task_id=...) after a short wait; if it "
|
|
"keeps timing out the repo may be too large or a remote "
|
|
"fetch is stalled"
|
|
),
|
|
task_id=str(task_id),
|
|
context_briefing={},
|
|
)
|
|
ev = build_evidence_for_task(
|
|
t,
|
|
journal_highlights=journal_highlights,
|
|
files_changed=files_changed,
|
|
pr_diff_summary=diff,
|
|
revision_findings=open_findings,
|
|
parent_context=parent_context,
|
|
)
|
|
return Envelope.ok(
|
|
status=str(t.status),
|
|
task_id=str(task_id),
|
|
next="continue",
|
|
evidence=ev.as_dict(),
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _evidence_git_and_fetch(
|
|
self, t: Any, agent_id: UUID
|
|
) -> tuple[str, list[str]]:
|
|
"""Git diff/fetch segment of evidence() — uses ``diff_and_files``."""
|
|
if not t.branch_name:
|
|
return "", []
|
|
git_start = time.monotonic()
|
|
diff, files_changed = await self.git.diff_and_files(
|
|
branch_name=t.branch_name, actor_agent_id=agent_id
|
|
)
|
|
git_ms = (time.monotonic() - git_start) * 1000
|
|
logger.info(
|
|
"evidence git diff/fetch timing",
|
|
git_diff_and_fetch_ms=round(git_ms, 1),
|
|
task_id=str(t.id),
|
|
)
|
|
return diff, files_changed
|
|
|
|
async def _evidence_db_reads(self, task_id: UUID) -> tuple[Any, Any, list[Any]]:
|
|
"""DB-reads segment of evidence() — runs concurrently with git work.
|
|
|
|
Returns ``(journal_highlights, parent_context, open_findings)``.
|
|
Sequential on purpose: all reads share the request-scoped AsyncSession
|
|
and asyncpg can't serve two in-flight queries on one connection.
|
|
"""
|
|
db_start = time.monotonic()
|
|
journal_highlights = await self.evidence_repo.journal_highlights_for_task(
|
|
task_id, include_ancestors=True
|
|
)
|
|
parent_context = await self.evidence_repo.ancestor_context_for_task(task_id)
|
|
open_findings = await findings_lib.open_findings_for_task(
|
|
self.task.session, task_id
|
|
)
|
|
db_ms = (time.monotonic() - db_start) * 1000
|
|
logger.info(
|
|
"evidence db reads timing",
|
|
db_reads_ms=round(db_ms, 1),
|
|
task_id=str(task_id),
|
|
)
|
|
return journal_highlights, parent_context, open_findings
|
|
|
|
async def _sandbox_active_task(self, agent_id: UUID) -> tuple[Any, Envelope | None]:
|
|
"""request_sandbox's task guard: an active, project-bound task, or a
|
|
clean invalid_state rejection."""
|
|
t = await self.task.get_active_task_for_agent(agent_id)
|
|
if t is None or t.project_id is None:
|
|
return None, Envelope.invalid_state(
|
|
message="no claimed task with a project — cannot scope a sandbox",
|
|
remediate=(
|
|
"call give_me_work() first; request_sandbox needs an "
|
|
"active, project-bound task"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return t, None
|
|
|
|
@staticmethod
|
|
def _sandbox_scope(
|
|
project: Any, services: list[str] | None
|
|
) -> tuple[frozenset[str], Envelope | None]:
|
|
"""request_sandbox's opt-in guard: the resolved service set, or a
|
|
clean invalid_state rejection naming the project's allowed set."""
|
|
opted = frozenset(project.sandbox_services or []) if project else frozenset()
|
|
if not opted:
|
|
return frozenset(), Envelope.invalid_state(
|
|
message="project has not opted into any sandbox service",
|
|
remediate=(
|
|
"ask a PM/CEO to set sandbox_services in project "
|
|
"settings before requesting a sandbox"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
requested = opted if services is None else frozenset(services)
|
|
unknown = requested - opted
|
|
if unknown:
|
|
return frozenset(), Envelope.invalid_state(
|
|
message=f"requested service(s) {sorted(unknown)} not opted into",
|
|
remediate=(
|
|
f"this project's opted-in set is {sorted(opted)} — "
|
|
"request a subset of that"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return requested, None
|
|
|
|
@staticmethod
|
|
def _validate_per_call_extensions(
|
|
extensions: dict[str, list[str]] | None,
|
|
opted: frozenset[str],
|
|
merged: dict[str, set[str]],
|
|
) -> Envelope | None:
|
|
"""Allowlist-validate the per-call extension map and union it into
|
|
``merged``; return a clean invalid_state rejection or None.
|
|
|
|
Extracted from ``_sandbox_features_scope`` so its loop's two rejection
|
|
branches don't push the caller over the complexity bound. A feature
|
|
for a non-opted service or outside the service's allowlist is rejected
|
|
naming the allowed set — the containment that keeps a ``plpython3u``
|
|
from reaching the provisioner.
|
|
"""
|
|
from roboco.models.sandbox import SANDBOX_ENGINE_FEATURES
|
|
|
|
for svc, feats in (extensions or {}).items():
|
|
if svc not in opted:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"extensions given for {svc!r}, which this project has "
|
|
f"not opted into"
|
|
),
|
|
remediate=(
|
|
f"this project's opted-in set is {sorted(opted)} — "
|
|
"request extensions only for those services"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset())
|
|
bad = sorted(set(feats or []) - allowed)
|
|
if bad:
|
|
return Envelope.invalid_state(
|
|
message=f"unallowed {svc} extension(s) {bad}",
|
|
remediate=(
|
|
f"the allowlist for {svc} is {sorted(allowed)} — "
|
|
"request a subset; plpython3u and other superuser-"
|
|
"language extensions are excluded by construction"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
merged.setdefault(svc, set()).update(feats or [])
|
|
return None
|
|
|
|
@staticmethod
|
|
def _sandbox_features_scope(
|
|
project: Any,
|
|
extensions: dict[str, list[str]] | None,
|
|
opted: frozenset[str],
|
|
) -> tuple[dict[str, list[str]], Envelope | None]:
|
|
"""request_sandbox's extension guard: the per-service feature map to
|
|
activate (project standing union per-call, bounded by the opted set +
|
|
the allowlist), or a clean invalid_state rejection.
|
|
|
|
Per-call ``extensions`` is allowlist-validated HERE (not only at the
|
|
provisioner) so a ``plpython3u`` gets a remediate naming the allowed
|
|
set, mirroring the unknown-service remediate. The project's standing
|
|
``sandbox_extensions`` was allowlist-validated at write time, so it is
|
|
trusted and unioned in; entries for a service no longer opted into are
|
|
dropped (a venture may deactivate a service without clearing its
|
|
standing extensions). Returns only services with a non-empty feature
|
|
list — a service with no features is bare (the provisioner's default).
|
|
"""
|
|
standing = (project.sandbox_extensions if project else None) or {}
|
|
# Union per service: standing (trusted) + per-call (validated below).
|
|
merged: dict[str, set[str]] = {}
|
|
for svc, feats in standing.items():
|
|
if svc in opted:
|
|
merged.setdefault(svc, set()).update(feats or [])
|
|
rejection = ContentActions._validate_per_call_extensions(
|
|
extensions, opted, merged
|
|
)
|
|
if rejection is not None:
|
|
return {}, rejection
|
|
return {svc: sorted(f) for svc, f in merged.items() if f}, None
|
|
|
|
async def _sandbox_provision_or_reject(
|
|
self,
|
|
agent_slug: str,
|
|
requested: frozenset[str],
|
|
opted: frozenset[str],
|
|
features: dict[str, list[str]] | None,
|
|
task_id: UUID,
|
|
) -> tuple[Any, Envelope | None]:
|
|
"""Run ``ensure_sandbox`` for request_sandbox; return (info, None) on
|
|
success or (None, rejection) when the orchestrator handle is missing
|
|
or provisioning raises. Extracted so ``request_sandbox``'s
|
|
orchestrator-None guard + try/except don't push it over the
|
|
complexity bound. Heartbeats the caller's task on success.
|
|
"""
|
|
if self.orchestrator is None:
|
|
return None, Envelope.invalid_state(
|
|
message="orchestrator handle unavailable — cannot provision a sandbox",
|
|
remediate=(
|
|
"retry request_sandbox shortly; the orchestrator may be restarting"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
from roboco.runtime.sandbox import SandboxProvisionError
|
|
|
|
try:
|
|
info = await self.orchestrator.ensure_sandbox(
|
|
agent_slug, sorted(requested), sorted(opted), features=features or None
|
|
)
|
|
except SandboxProvisionError as e:
|
|
return None, Envelope.invalid_state(
|
|
message=f"sandbox provisioning failed: {e}",
|
|
remediate="retry shortly; escalate to your PM if it keeps failing",
|
|
context_briefing={},
|
|
)
|
|
await self._touch_heartbeat(task_id)
|
|
return info, None
|
|
|
|
async def request_sandbox(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
services: list[str] | None = None,
|
|
extensions: dict[str, list[str]] | None = None,
|
|
) -> Envelope:
|
|
"""On-demand sandbox DB/Redis/Mongo (dev + QA only, see role_config).
|
|
|
|
Replaces eager per-spawn provisioning: a sandbox is created only when
|
|
an agent actually asks for one, keyed off the CALLER's authenticated
|
|
slug (never another agent's). ``services`` omitted means the
|
|
project's whole opted-in set. ``extensions`` (per-service
|
|
extensions/modules, e.g. ``{"postgres": ["vector"]}``) is an additive
|
|
per-call override unioned with the project's standing
|
|
``sandbox_extensions`` and bounded by the opted set + the allowlist —
|
|
a ``plpython3u`` is rejected here with the allowed set named.
|
|
|
|
Guards, in order: flag off; caller has no claimed/active,
|
|
project-bound task (`_sandbox_active_task`); project not opted into
|
|
any sandbox service, or a requested service outside its opted set
|
|
(`_sandbox_scope`, names the allowed set); per-call extensions for a
|
|
non-opted service or outside the allowlist (`_sandbox_features_scope`,
|
|
names the allowed set); orchestrator handle unavailable (retryable).
|
|
`ensure_sandbox` always provisions the project's whole opted-in set
|
|
regardless of ``services`` (so a later call can never trigger a
|
|
mid-session teardown of a live container); the evidence payload here
|
|
is filtered back down to what THIS call asked for. Creds come back in
|
|
the evidence payload, never as injected env — see
|
|
``docs/internal/specs/2026-07-08-sandbox-on-demand.md`` §4.
|
|
"""
|
|
if not settings.sandbox_db_enabled:
|
|
return Envelope.invalid_state(
|
|
message="sandbox provisioning is disabled",
|
|
remediate=(
|
|
"ROBOCO_SANDBOX_DB_ENABLED is off — ask the CEO to arm "
|
|
"it, or rely on the legacy gate env if this project "
|
|
"isn't sandboxed"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
t, rejection = await self._sandbox_active_task(agent_id)
|
|
if rejection is not None:
|
|
return rejection
|
|
from roboco.services.project import get_project_service
|
|
|
|
project = await get_project_service(self.task.session).get(t.project_id)
|
|
requested, rej_scope = self._sandbox_scope(project, services)
|
|
opted = frozenset(project.sandbox_services or []) if project else frozenset()
|
|
features, rej_features = self._sandbox_features_scope(
|
|
project, extensions, opted
|
|
)
|
|
# Scope before features: an unknown-service rejection wins over a
|
|
# per-call extension rejection for the same call.
|
|
rejection = rej_scope or rej_features
|
|
if rejection is not None:
|
|
return rejection
|
|
from roboco.agents_config import _resolve_to_slug
|
|
|
|
agent_slug = _resolve_to_slug(str(agent_id))
|
|
info, rej = await self._sandbox_provision_or_reject(
|
|
agent_slug, requested, opted, features, t.id
|
|
)
|
|
if rej is not None:
|
|
return rej
|
|
# ensure_sandbox provisions the project's whole opted-in set (see its
|
|
# docstring); the evidence payload stays scoped to what THIS call
|
|
# asked for.
|
|
payload = info.as_payload()
|
|
filtered = {
|
|
name: payload[name] for name in sorted(requested) if name in payload
|
|
}
|
|
return Envelope.ok(
|
|
status=str(t.status),
|
|
task_id=str(t.id),
|
|
next="use the returned creds for this session; call again anytime",
|
|
evidence=filtered,
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _render_active_video_task(
|
|
self, agent_id: UUID
|
|
) -> tuple[Any, Envelope | None]:
|
|
"""request_render's task guard: an active, project-bound video-
|
|
authoring task, or a clean invalid_state rejection."""
|
|
from roboco.services.task import VIDEO_SOURCE
|
|
|
|
t = await self.task.get_active_task_for_agent(agent_id)
|
|
if t is None or t.project_id is None or t.source != VIDEO_SOURCE:
|
|
return None, Envelope.invalid_state(
|
|
message="no active video-authoring task assigned to you",
|
|
remediate=(
|
|
"request_render is only available on a video-authoring "
|
|
"task — claim your assigned authoring task first"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return t, None
|
|
|
|
@staticmethod
|
|
def _render_resolve_composition_id(
|
|
task: Any, composition_id: str | None
|
|
) -> tuple[str, Envelope | None]:
|
|
"""Explicit ``composition_id``, else the task's ``video_draft``
|
|
marker's, validated with the SAME charset regex ``propose_video``
|
|
enforces so an unrenderable id is refused here, not deep inside the
|
|
sidecar call."""
|
|
resolved = composition_id or (markers.get_video_draft(task) or {}).get(
|
|
"composition_id"
|
|
)
|
|
if not resolved or not str(resolved).strip():
|
|
return "", Envelope.incomplete_input(
|
|
missing=["composition_id"],
|
|
field_hints={"composition_id": "the HyperFrames composition id"},
|
|
remediate=(
|
|
"pass composition_id explicitly, or call propose_video "
|
|
"first so it's on the task's video_draft marker"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
resolved = str(resolved).strip()
|
|
if not _COMPOSITION_ID_RE.fullmatch(resolved):
|
|
return "", Envelope.invalid_state(
|
|
message=f"composition_id {resolved!r} is not renderable",
|
|
remediate=(
|
|
"letters, digits, '_' or '-' with optional interior dots — "
|
|
"match the directory name under motion/compositions/"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return resolved, None
|
|
|
|
_RENDER_ORIENTATIONS: ClassVar[frozenset[str]] = frozenset({"vertical", "square"})
|
|
_RENDER_MAX_FRAMES: ClassVar[int] = 32
|
|
|
|
@classmethod
|
|
def _render_validate_params(
|
|
cls, orientation: str, frame_count: int
|
|
) -> Envelope | None:
|
|
"""``orientation``/``frame_count`` bounds guard, folded into one
|
|
rejection point so ``request_render`` keeps one return per guard."""
|
|
if orientation not in cls._RENDER_ORIENTATIONS:
|
|
return Envelope.invalid_state(
|
|
message=f"orientation {orientation!r} must be 'vertical' or 'square'",
|
|
remediate="pass orientation='vertical' or orientation='square'",
|
|
context_briefing={},
|
|
)
|
|
if not isinstance(frame_count, int) or not (
|
|
1 <= frame_count <= cls._RENDER_MAX_FRAMES
|
|
):
|
|
bound = cls._RENDER_MAX_FRAMES
|
|
return Envelope.invalid_state(
|
|
message=f"frame_count {frame_count!r} must be an integer 1-{bound}",
|
|
remediate=f"pass frame_count between 1 and {bound}",
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _render_resolve_input_props(
|
|
task: Any, input_props: dict[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
if input_props is not None:
|
|
return input_props
|
|
draft = markers.get_video_draft(task) or {}
|
|
return draft.get("input_props") or draft.get("suggested_input_props") or {}
|
|
|
|
@staticmethod
|
|
async def _render_git_rev(workspace: Path, ref: str) -> str | None:
|
|
"""Best-effort ``git rev-parse <ref>`` in ``workspace``; ``None`` on
|
|
any failure (missing ref, missing dir, no git binary). The render
|
|
preview's provenance stamp is best-effort — a git hiccup must never
|
|
block the render response itself."""
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"git",
|
|
"-C",
|
|
str(workspace),
|
|
"rev-parse",
|
|
ref,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
out, _ = await proc.communicate()
|
|
except OSError:
|
|
return None
|
|
if proc.returncode != 0:
|
|
return None
|
|
sha = out.decode().strip()
|
|
return sha or None
|
|
|
|
@classmethod
|
|
async def _render_git_head_and_dirty(
|
|
cls, workspace: Path
|
|
) -> tuple[str | None, bool]:
|
|
"""Best-effort ``(HEAD sha, has-uncommitted-changes)`` for a dev's own
|
|
working tree. ``(None, False)`` on any failure."""
|
|
head = await cls._render_git_rev(workspace, "HEAD")
|
|
if head is None:
|
|
return None, False
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"git",
|
|
"-C",
|
|
str(workspace),
|
|
"status",
|
|
"--porcelain",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
out, _ = await proc.communicate()
|
|
except OSError:
|
|
return head, False
|
|
dirty = bool(out.decode().strip()) if proc.returncode == 0 else False
|
|
return head, dirty
|
|
|
|
async def _render_dev_source(
|
|
self, agent_id: UUID, agent_slug: str, task: Any, project: Any
|
|
) -> tuple[Any, Envelope | None]:
|
|
"""A developer's own working tree — the per-task worktree when one
|
|
exists on disk, else the clone root (F123)."""
|
|
from roboco.services.workspace import WorkspaceError
|
|
|
|
agent = await self.task.agent_for(agent_id)
|
|
if agent is None or not agent.team:
|
|
return None, Envelope.invalid_state(
|
|
message="your team could not be resolved",
|
|
remediate="ensure your agent record has a team, then retry",
|
|
context_briefing={},
|
|
)
|
|
try:
|
|
clone_root = self.workspace.get_clone_root_path(
|
|
project.slug, agent.team, agent_slug
|
|
)
|
|
worktree = self.workspace.get_worktree_path(
|
|
project.slug, agent.team, agent_slug, task.id.hex[:8]
|
|
)
|
|
except WorkspaceError as exc:
|
|
return None, Envelope.invalid_state(
|
|
message=f"could not resolve your workspace path: {exc}",
|
|
remediate="retry request_render; escalate to your PM if it persists",
|
|
context_briefing={},
|
|
)
|
|
root = worktree if worktree.exists() else clone_root
|
|
head_sha, dirty = await self._render_git_head_and_dirty(root)
|
|
return (
|
|
_RenderSource(root=root, head_sha=head_sha, dirty=dirty, kind="workspace"),
|
|
None,
|
|
)
|
|
|
|
async def _render_qa_source(
|
|
self, task: Any, project: Any
|
|
) -> tuple[Any, Envelope | None]:
|
|
"""QA never renders from a working tree: a read-only export of the
|
|
assembled branch's ``motion/`` subtree via
|
|
``WorkspaceService.export_branch_motion``."""
|
|
from roboco.services.workspace import WorkspaceError
|
|
|
|
branch = getattr(task, "branch_name", None)
|
|
if not branch:
|
|
return None, Envelope.invalid_state(
|
|
message="task has no recorded branch to export for a QA render",
|
|
remediate=(
|
|
"the assembled PR's branch must exist before requesting a render"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
try:
|
|
scratch = await self.workspace.export_branch_motion(project, branch)
|
|
except WorkspaceError as exc:
|
|
return None, Envelope.invalid_state(
|
|
message=f"could not export branch {branch!r} for render: {exc}",
|
|
remediate=(
|
|
"ensure the branch is pushed to origin, then retry request_render"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
read_clone = await self.workspace.ensure_read_clone(project.slug)
|
|
head_sha = await self._render_git_rev(
|
|
read_clone, f"refs/remotes/origin/{branch}"
|
|
)
|
|
|
|
def _cleanup() -> None:
|
|
shutil.rmtree(scratch, ignore_errors=True)
|
|
|
|
return (
|
|
_RenderSource(
|
|
root=scratch,
|
|
head_sha=head_sha,
|
|
dirty=False,
|
|
kind="branch",
|
|
cleanup=_cleanup,
|
|
),
|
|
None,
|
|
)
|
|
|
|
async def _render_resolve_source(
|
|
self, agent_id: UUID, agent_slug: str, task: Any, project: Any
|
|
) -> tuple[Any, Envelope | None]:
|
|
"""Dispatch the render SOURCE by the caller's role: developer → their
|
|
own tree; QA → a read-only branch export; anyone else → refused."""
|
|
role = await self._caller_role(agent_id)
|
|
if role == "developer":
|
|
return await self._render_dev_source(agent_id, agent_slug, task, project)
|
|
if role == "qa":
|
|
return await self._render_qa_source(task, project)
|
|
return None, Envelope.not_authorized(
|
|
message=f"role {role!r} may not request a render",
|
|
remediate="request_render is developer/QA only",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def _render_resolve_project_and_source(
|
|
self, agent_id: UUID, agent_slug: str, task: Any
|
|
) -> tuple[Any, Envelope | None]:
|
|
"""Resolve the task's project, then the caller's render source —
|
|
bundled into one combined-rejection helper so ``request_render``'s
|
|
own return count stays under the xenon/PLR0911 budget. Success value
|
|
is a ``(project, source)`` pair."""
|
|
from roboco.services.project import get_project_service
|
|
|
|
project = await get_project_service(self.task.session).get(task.project_id)
|
|
if project is None:
|
|
return None, Envelope.invalid_state(
|
|
message="task's project could not be resolved",
|
|
remediate="retry shortly; the project record may be mid-update",
|
|
context_briefing={},
|
|
)
|
|
source, rejection = await self._render_resolve_source(
|
|
agent_id, agent_slug, task, project
|
|
)
|
|
if rejection is not None:
|
|
return None, rejection
|
|
return (project, source), None
|
|
|
|
def _render_extract_frames(
|
|
self, project_slug: str, task_id: Any, orientation: str, frames_tar_gz: bytes
|
|
) -> list[str]:
|
|
"""Extract the sidecar's frames tar.gz to the container-shared
|
|
preview dir, wiping any stale render first; returns sorted absolute
|
|
frame paths. Every agent container mounts the same /data/workspaces
|
|
volume, so this path is identical inside every container regardless
|
|
of who rendered — that's what lets a dev render and a QA (or PM)
|
|
read the same frames from their own container."""
|
|
out_dir = (
|
|
Path(settings.workspaces_root)
|
|
/ project_slug
|
|
/ ".previews"
|
|
/ task_id.hex[:8]
|
|
/ orientation
|
|
)
|
|
if out_dir.exists():
|
|
shutil.rmtree(out_dir)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
with tarfile.open(fileobj=io.BytesIO(frames_tar_gz)) as tar:
|
|
tar.extractall(out_dir, filter="data")
|
|
return sorted(str(p) for p in out_dir.rglob("*") if p.is_file())
|
|
|
|
async def _render_execute(
|
|
self,
|
|
*,
|
|
task: Any,
|
|
project: Any,
|
|
agent_slug: str,
|
|
resolved_id: str,
|
|
resolved_props: dict[str, Any],
|
|
orientation: str,
|
|
frame_count: int,
|
|
source: Any,
|
|
) -> Envelope:
|
|
"""The render call + frame extraction + marker stamp, once every
|
|
guard in ``request_render`` has passed. Always cleans up a QA
|
|
scratch-dir source (dev sources have no cleanup callback)."""
|
|
try:
|
|
comp_dir = source.root / "motion" / "compositions" / resolved_id
|
|
if not comp_dir.is_dir():
|
|
expected = f"motion/compositions/{resolved_id}/"
|
|
return Envelope.invalid_state(
|
|
message=f"no composition found at {expected}",
|
|
remediate=(
|
|
f"build the composition under {expected} first "
|
|
"(propose_video / commit it), then retry request_render"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
from roboco.services.video_renderer_client import (
|
|
VideoRendererError,
|
|
get_video_renderer,
|
|
)
|
|
|
|
try:
|
|
frames_tar_gz, duration = await get_video_renderer().render_frames(
|
|
str(source.root / "motion"),
|
|
composition_id=resolved_id,
|
|
input_props=resolved_props,
|
|
orientation=orientation,
|
|
frame_count=frame_count,
|
|
)
|
|
except VideoRendererError as exc:
|
|
return Envelope.invalid_state(
|
|
message=f"render failed: {exc}",
|
|
remediate=(
|
|
"retry request_render shortly; escalate to your PM if "
|
|
"it keeps failing"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
frames = self._render_extract_frames(
|
|
project.slug, task.id, orientation, frames_tar_gz
|
|
)
|
|
payload = {
|
|
"at": datetime.now(UTC).isoformat(),
|
|
"composition_id": resolved_id,
|
|
"orientation": orientation,
|
|
"frame_count": frame_count,
|
|
"duration_seconds": duration,
|
|
"frames": frames,
|
|
"head_sha": source.head_sha,
|
|
"dirty": source.dirty,
|
|
"rendered_by": agent_slug,
|
|
"source": source.kind,
|
|
}
|
|
markers.set_render_preview(task, payload)
|
|
# The post-completion render loop keys on video_draft.composition_id
|
|
# — a dev who only ever passed composition_id explicitly (never
|
|
# propose_video) must still leave it stamped, or the loop skips the
|
|
# completed task silently (proven live on task 1dae04a7).
|
|
draft = markers.get_video_draft(task) or {}
|
|
if not draft.get("composition_id"):
|
|
markers.set_video_draft(task, {**draft, "composition_id": resolved_id})
|
|
await self.task.session.flush()
|
|
await self._touch_heartbeat(task.id)
|
|
return Envelope.ok(
|
|
status=str(task.status),
|
|
task_id=str(task.id),
|
|
next=(
|
|
"Read every frames[] path with your file tools; if any "
|
|
"scene is missing or clipped, fix the composition and "
|
|
"call request_render again."
|
|
),
|
|
evidence={
|
|
**payload,
|
|
"note": (
|
|
"Read each frame image and verify every scene/feature "
|
|
"from the brief appears fully and legibly before "
|
|
"i_am_done."
|
|
),
|
|
},
|
|
context_briefing={},
|
|
)
|
|
finally:
|
|
if source.cleanup is not None:
|
|
source.cleanup()
|
|
|
|
async def request_render(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
composition_id: str | None = None,
|
|
orientation: str = "vertical",
|
|
frame_count: int = 8,
|
|
input_props: dict[str, Any] | None = None,
|
|
) -> Envelope:
|
|
"""Render a video composition to a strip of preview frames the
|
|
caller reads with file tools — verifying the RENDERED artifact, not
|
|
just the HyperFrames source, which can look plausible and still
|
|
render wrong (missing scene, clipped layout, wrong text).
|
|
|
|
Guards, in order: video engine flag off; caller has no active,
|
|
project-bound video-authoring task (`_render_active_video_task`);
|
|
the renderer sidecar unconfigured (`video_renderer_base_url`);
|
|
`composition_id` unresolvable or failing `propose_video`'s own
|
|
charset regex, or `orientation`/`frame_count` out of range; the
|
|
caller's role-based SOURCE (`_render_resolve_source` — a developer's
|
|
own tree, or QA's read-only branch export, never a QA working tree);
|
|
the resolved source missing `motion/compositions/<id>/`; the sidecar
|
|
call itself (`VideoRendererError` -> a retryable rejection). On
|
|
success, extracts the returned frames to a container-shared
|
|
`.previews/<task>/<orientation>/` path and stamps `render_preview` —
|
|
the marker `i_am_done`'s RENDER_VERIFIED gate checks.
|
|
"""
|
|
if not settings.video_engine_enabled:
|
|
return Envelope.invalid_state(
|
|
message="the video engine is disabled",
|
|
remediate="ROBOCO_VIDEO_ENGINE_ENABLED is off — nothing to render",
|
|
context_briefing={},
|
|
)
|
|
t, rejection = await self._render_active_video_task(agent_id)
|
|
if rejection is not None:
|
|
return rejection
|
|
renderer_rej = (
|
|
None
|
|
if settings.video_renderer_base_url.strip()
|
|
else Envelope.invalid_state(
|
|
message="the video-renderer sidecar is not configured",
|
|
remediate="ask the CEO to set ROBOCO_VIDEO_RENDERER_BASE_URL",
|
|
context_briefing={},
|
|
)
|
|
)
|
|
resolved_id, rej_id = self._render_resolve_composition_id(t, composition_id)
|
|
rej_params = self._render_validate_params(orientation, frame_count)
|
|
rejection = renderer_rej or rej_id or rej_params
|
|
if rejection is not None:
|
|
return rejection
|
|
resolved_props = self._render_resolve_input_props(t, input_props)
|
|
|
|
from roboco.agents_config import _resolve_to_slug
|
|
|
|
agent_slug = _resolve_to_slug(str(agent_id))
|
|
resolved, rejection = await self._render_resolve_project_and_source(
|
|
agent_id, agent_slug, t
|
|
)
|
|
if rejection is not None:
|
|
return rejection
|
|
project, source = resolved
|
|
return await self._render_execute(
|
|
task=t,
|
|
project=project,
|
|
agent_slug=agent_slug,
|
|
resolved_id=resolved_id,
|
|
resolved_props=resolved_props,
|
|
orientation=orientation,
|
|
frame_count=frame_count,
|
|
source=source,
|
|
)
|
|
|
|
# =========================================================================
|
|
# Wave 1 — pre-gateway parity restoration
|
|
# =========================================================================
|
|
|
|
_PROGRESS_ACTIVE_STATUSES: ClassVar[frozenset[str]] = frozenset(
|
|
{"in_progress", "verifying", "awaiting_qa", "awaiting_documentation"}
|
|
)
|
|
|
|
async def _progress_precondition_reject(
|
|
self, agent_id: UUID, task: Any
|
|
) -> Envelope | None:
|
|
"""Ownership + active-claim + active-status gate for progress().
|
|
|
|
Returns the rejection envelope, or None when all preconditions hold.
|
|
Extracted so ``progress`` stays under the return-count bound.
|
|
"""
|
|
if task.assigned_to != agent_id:
|
|
return _ownership_violation(task.id)
|
|
if reject := await self._active_claim_violation(agent_id, task):
|
|
return reject
|
|
if str(task.status) not in self._PROGRESS_ACTIVE_STATUSES:
|
|
return Envelope.invalid_state(
|
|
message=(
|
|
f"task is in {task.status!r}; progress updates only valid "
|
|
f"in active statuses ({sorted(self._PROGRESS_ACTIVE_STATUSES)})"
|
|
),
|
|
remediate=(
|
|
"use evidence(task_id) to re-read state; if you're past "
|
|
"i_am_done, the run has moved on — call i_am_idle()"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return None
|
|
|
|
async def progress(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
task_id: UUID,
|
|
message: str,
|
|
plan_step: str | None = None,
|
|
percentage: int | None = None,
|
|
) -> Envelope:
|
|
"""Append a progress update; % is derived from the plan checklist.
|
|
|
|
Pass ``plan_step`` (a sub_task id or 1-based order) as you
|
|
finish each plan step — it is marked complete and the % is
|
|
computed from completed/total (the agent cannot set it). A
|
|
narrative entry without ``plan_step`` is allowed for important
|
|
mid-step documentation and carries the current derived %.
|
|
``percentage`` is only a fallback for tasks with no checklist.
|
|
|
|
Omitting ``plan_step`` on a task that *has* steps is accepted (a
|
|
product decision — narrative mid-step updates are valid) but logs a
|
|
soft warning so the gap is visible. It is never rejected.
|
|
|
|
Caller must be the active claimant and the task must be in an
|
|
active status — same constraints as the pre-gateway handler, plus
|
|
the single-claimant guard so a reaped/handed-off assignee cannot
|
|
keep writing.
|
|
"""
|
|
if rej := self._reject_soup(message, field="progress update", min_chars=5):
|
|
return rej
|
|
t = await self.task.get(task_id)
|
|
if t is None:
|
|
return Envelope.not_found(message=f"task {task_id} not found")
|
|
if reject := await self._progress_precondition_reject(agent_id, t):
|
|
return reject
|
|
result = await self.task.record_plan_progress(
|
|
task_id=task_id,
|
|
agent_id=agent_id,
|
|
message=message,
|
|
plan_step=plan_step,
|
|
fallback_percentage=percentage,
|
|
)
|
|
if result is None:
|
|
return Envelope.not_found(message=f"task {task_id} not found")
|
|
if result["step_resolved"] is False:
|
|
valid = result["valid_steps"]
|
|
return Envelope.invalid_state(
|
|
message=f"plan_step {plan_step!r} does not match any plan step",
|
|
remediate=(
|
|
"pass a sub_task id or its 1-based order. Valid steps: "
|
|
f"{valid}. Re-read them with evidence(task_id)."
|
|
),
|
|
context_briefing={},
|
|
)
|
|
if plan_step is None and result["valid_steps"]:
|
|
logger.warning(
|
|
"progress() called without plan_step on a stepped task",
|
|
task_id=str(task_id),
|
|
agent_id=str(agent_id),
|
|
valid_steps=result["valid_steps"],
|
|
)
|
|
await self._touch_heartbeat(task_id)
|
|
return Envelope.ok(
|
|
status=str(t.status),
|
|
task_id=str(task_id),
|
|
next=f"progress {result['percentage']}% — continue",
|
|
context_briefing={},
|
|
)
|
|
|
|
async def notify_list(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
unread_only: bool = True,
|
|
pending_ack_only: bool = False,
|
|
limit: int = 20,
|
|
) -> Envelope:
|
|
"""Read this agent's notification inbox.
|
|
|
|
Closes the pre-gateway parity gap that left `i_am_idle()` deadlocked:
|
|
the verb is documented to soft-block on unread notifications, but
|
|
previously there was no way for the agent to read or acknowledge them.
|
|
"""
|
|
items = await self._deps.notification_delivery.list_for_agent(
|
|
agent_id=agent_id,
|
|
unread_only=unread_only,
|
|
pending_ack_only=pending_ack_only,
|
|
type_filter=None,
|
|
limit=limit,
|
|
)
|
|
notifications = [
|
|
{
|
|
"id": str(n.id),
|
|
"type": str(n.type),
|
|
"priority": str(n.priority),
|
|
"subject": n.subject,
|
|
"body": n.body,
|
|
"requires_ack": n.requires_ack,
|
|
"timestamp": n.timestamp.isoformat() if n.timestamp else None,
|
|
"from_agent": str(n.from_agent) if n.from_agent else None,
|
|
}
|
|
for n in items
|
|
]
|
|
return Envelope.ok(
|
|
status="ok",
|
|
task_id=None,
|
|
next="continue",
|
|
evidence={"notifications": notifications, "count": len(notifications)},
|
|
context_briefing={},
|
|
)
|
|
|
|
async def notify_get(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
notification_id: UUID,
|
|
) -> Envelope:
|
|
"""Read one notification (also marks it read)."""
|
|
try:
|
|
n = await self._deps.notification_delivery.get_for_recipient_and_mark_read(
|
|
notification_id=notification_id,
|
|
agent_id=agent_id,
|
|
)
|
|
except Exception:
|
|
return Envelope.not_found(
|
|
message=f"notification {notification_id} not found"
|
|
)
|
|
return Envelope.ok(
|
|
status="ok",
|
|
task_id=None,
|
|
next="continue",
|
|
evidence={
|
|
"id": str(n.id),
|
|
"type": str(n.type),
|
|
"priority": str(n.priority),
|
|
"subject": n.subject,
|
|
"body": n.body,
|
|
"requires_ack": n.requires_ack,
|
|
"from_agent": str(n.from_agent) if n.from_agent else None,
|
|
},
|
|
context_briefing={},
|
|
)
|
|
|
|
_PM_ROLES_FOR_PR_UPDATE: ClassVar[frozenset[str]] = frozenset(
|
|
{"cell_pm", "main_pm"}
|
|
)
|
|
|
|
@staticmethod
|
|
def _pr_update_is_authorized(agent_id: UUID, task: Any, agent: Any) -> bool:
|
|
"""True iff caller is the task's assignee, main_pm, or cell_pm on team.
|
|
|
|
Extracted so pr_update stays under xenon's cyclomatic-complexity
|
|
bound — the three branches plus the team-string compare push the
|
|
verb itself over the line when inlined.
|
|
"""
|
|
if task.assigned_to == agent_id:
|
|
return True
|
|
role_str = str(agent.role) if agent is not None else ""
|
|
if role_str == "main_pm":
|
|
return True
|
|
if role_str != "cell_pm" or agent is None:
|
|
return False
|
|
if agent.team is None or task.team is None:
|
|
return False
|
|
return str(agent.team) == str(task.team)
|
|
|
|
async def pr_update(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
task_id: UUID,
|
|
title: str | None = None,
|
|
body: str | None = None,
|
|
reviewers: list[str] | None = None,
|
|
) -> Envelope:
|
|
"""Update an existing PR's title, body, and/or requested reviewers.
|
|
|
|
Dogfooding surfaced this gap: agents who needed to edit a PR's
|
|
title/body or assign a reviewer after ``open_pr`` had no verb
|
|
for it and got bash-shimmed by the ``gh pr edit`` guard. This
|
|
verb is the gateway-native replacement.
|
|
|
|
Authorization: caller must be the task's ``assigned_to`` OR a
|
|
PM on the task's team (cell_pm.team == task.team, or main_pm
|
|
which is cross-team).
|
|
|
|
Preconditions:
|
|
- task must exist (else not_found)
|
|
- task.pr_number must be set (else invalid_state, remediate
|
|
'call open_pr')
|
|
- at least one of title/body/reviewers must be non-None (else
|
|
invalid_state — schema-level check is the first line of
|
|
defense; this guard catches direct gateway calls)
|
|
"""
|
|
if rej := self._pr_update_input_check(title, body, reviewers):
|
|
return rej
|
|
t = await self.task.get(task_id)
|
|
if t is None:
|
|
return Envelope.not_found(message=f"task {task_id} not found")
|
|
if t.pr_number is None:
|
|
return Envelope.invalid_state(
|
|
message=f"task {task_id} has no PR open",
|
|
remediate=(
|
|
"call open_pr(task_id) first; pr_update only edits an "
|
|
"already-open PR"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
agent = await self.task.agent_for(agent_id)
|
|
if not self._pr_update_is_authorized(agent_id, t, agent):
|
|
role_str = str(agent.role) if agent is not None else ""
|
|
return Envelope.not_authorized(
|
|
message=(
|
|
f"role {role_str!r} is neither the assignee nor a PM on "
|
|
f"this task's team; cannot update PR"
|
|
),
|
|
remediate=(
|
|
"only the task's assignee or a PM on the task's team "
|
|
"may edit the PR; ask the assignee or your PM to call "
|
|
"pr_update instead"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
try:
|
|
result = await self.git.update_pr_for_task(
|
|
task_id,
|
|
title=title,
|
|
body=body,
|
|
reviewers=reviewers,
|
|
actor_agent_id=agent_id,
|
|
)
|
|
except GitError as exc:
|
|
return Envelope.invalid_state(
|
|
message=str(exc),
|
|
remediate=(
|
|
"check the PR number on the task and retry; if the PR "
|
|
"was closed externally, the task should be reset"
|
|
),
|
|
context_briefing={},
|
|
)
|
|
return Envelope.ok(
|
|
status=str(t.status),
|
|
task_id=str(task_id),
|
|
next="continue working, or i_am_done when ready",
|
|
evidence=result,
|
|
context_briefing={},
|
|
)
|
|
|
|
async def notify_ack(
|
|
self,
|
|
*,
|
|
agent_id: UUID,
|
|
notification_id: UUID,
|
|
) -> Envelope:
|
|
"""Acknowledge a notification.
|
|
|
|
Returns ``not_authorized`` if the caller isn't a recipient.
|
|
"""
|
|
try:
|
|
n = await self._deps.notification_delivery.acknowledge(
|
|
notification_id=notification_id,
|
|
agent_id=agent_id,
|
|
ack_type="received",
|
|
)
|
|
except ValueError as exc:
|
|
return Envelope.not_authorized(
|
|
message=str(exc),
|
|
remediate="only recipients of a notification can ack it",
|
|
context_briefing={},
|
|
)
|
|
if n is None:
|
|
return Envelope.not_found(
|
|
message=f"notification {notification_id} not found"
|
|
)
|
|
return Envelope.ok(
|
|
status="acked",
|
|
task_id=None,
|
|
next="continue",
|
|
evidence={"id": str(notification_id), "acked": True},
|
|
context_briefing={},
|
|
)
|
|
|
|
async def read_messages(self, *, agent_id: UUID) -> Envelope:
|
|
"""Mark all of the caller's unread A2A direct messages as read.
|
|
|
|
Clears the A2A side of ``i_am_idle``'s unread soft-block: zeroes the
|
|
per-conversation unread counter and stamps ``read_at`` on the inbound
|
|
messages. Notifications are separate (notify_list / notify_get /
|
|
notify_ack).
|
|
"""
|
|
cleared = await self.a2a.mark_all_read(agent_id)
|
|
return Envelope.ok(
|
|
status="read",
|
|
task_id=None,
|
|
next="retry i_am_idle() — your A2A inbox is clear",
|
|
evidence={"conversations_cleared": cleared},
|
|
context_briefing={},
|
|
)
|
|
|
|
async def read_a2a(self, *, agent_id: UUID) -> Envelope:
|
|
"""Return the caller's unread INCOMING A2A message bodies, then clear them.
|
|
|
|
The content-bearing read: ``read_messages`` only zeroes the unread
|
|
counter, so an agent could see "3 unread from be-qa" without ever
|
|
reading what was said. This returns the actual text of the inbound
|
|
messages (never the caller's own sends) so it can act on them.
|
|
"""
|
|
messages = await self.a2a.get_unread_messages(agent_id)
|
|
return Envelope.ok(
|
|
status="read",
|
|
task_id=None,
|
|
next="act on the messages, then retry i_am_idle()",
|
|
evidence={"messages": messages},
|
|
context_briefing={},
|
|
)
|
|
|
|
|
|
def _strip_task_prefix(msg: str) -> str:
|
|
"""Strip any [task-id] prefix the agent supplied; gateway re-adds canonical."""
|
|
return _TASK_ID_PREFIX_RE.sub("", msg)
|
|
|
|
|
|
_AI_ATTRIBUTION_RE = re.compile(
|
|
r"co-authored-by:.*(?:anthropic\.com|claude|grok|x\.?ai)"
|
|
r"|generated with.*(?:claude|grok)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _strip_ai_attribution(msg: str) -> str:
|
|
"""Drop model self-attribution lines from a commit message.
|
|
|
|
Company policy: agent commits carry the agent's own identity, never the
|
|
model vendor's. The settings-level ``includeCoAuthoredBy: false`` removes
|
|
the harness nudge, but the model can still hand-write the trailer — this
|
|
chokepoint covers every provider deterministically.
|
|
"""
|
|
kept = [ln for ln in msg.splitlines() if not _AI_ATTRIBUTION_RE.search(ln)]
|
|
return "\n".join(kept)
|