mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(x-engine): smart spotlight cadence — daily when there's news, quiet when there isn't (#374)
CEO verdict on the blind 3-day timer: 'It should default to 1 day and be more like... smart.' Now: interval defaults to 1 day; the cycle skips (with logged reasons) while a spotlight draft is still awaiting the CEO, and stretches to 3x the interval when nothing has shipped (CHANGELOG sections via the read clone) since the last spotlight activity — where activity is a materialized draft's seen_at or a completed exploration's updated_at, deliberately excluding the stale- cycle janitor's cancels. The HoM gains an explicit skip exit (propose_feature_spotlight skip=true + reason: completes the exploration, no draft, no seen-slug, still counts as activity), and its spawn prompt now carries the seen ledger WITH dates, what shipped since the last spotlight, and recently rejected drafts with the CEO's reasons — fresh-but-unspotlighted first. Fail-open on changelog read errors so a signal outage never starves the engine. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -25,4 +25,4 @@
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
| `propose_feature_spotlight` | `propose_feature_spotlight(feature_slug: str, feature_title: str, body: str, wants_video: bool = False, video_script: str = '')` |
|
||||
| `propose_feature_spotlight` | `propose_feature_spotlight(feature_slug: str = '', feature_title: str = '', body: str = '', wants_video: bool = False, video_script: str = '', skip: bool = False, skip_reason: str = '')` |
|
||||
|
||||
@@ -231,7 +231,7 @@ real tools live in their agent_sdk drivers, not role_config.
|
||||
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||
| `read_messages` | `read_messages()` |
|
||||
| `read_a2a` | `read_a2a(see do_server)` |
|
||||
| `propose_feature_spotlight` | `propose_feature_spotlight(feature_slug: str, feature_title: str, body: str, wants_video: bool = False, video_script: str = '')` |
|
||||
| `propose_feature_spotlight` | `propose_feature_spotlight(feature_slug: str = '', feature_title: str = '', body: str = '', wants_video: bool = False, video_script: str = '', skip: bool = False, skip_reason: str = '')` |
|
||||
|
||||
## auditor
|
||||
|
||||
|
||||
@@ -176,6 +176,8 @@ async def do_propose_feature_spotlight(
|
||||
body=body.body,
|
||||
wants_video=body.wants_video,
|
||||
video_script=body.video_script,
|
||||
skip=body.skip,
|
||||
skip_reason=body.skip_reason,
|
||||
)
|
||||
return envelope_to_response(env, request)
|
||||
|
||||
|
||||
@@ -130,13 +130,19 @@ class ProposeRoadmapRequest(BaseModel):
|
||||
|
||||
class ProposeFeatureSpotlightRequest(BaseModel):
|
||||
"""Head of Marketing's feature-spotlight draft: a picked feature + a
|
||||
ready-to-post body, plus an optional companion-video request."""
|
||||
ready-to-post body, plus an optional companion-video request — or a
|
||||
``skip`` verdict when nothing is worth spotlighting this cycle (the
|
||||
feature/title/body fields are then ignored; the handler enforces the
|
||||
real "required unless skipping" rule since a plain Field(..., min_length)
|
||||
can't express that conditional)."""
|
||||
|
||||
feature_slug: str = Field(..., min_length=1, max_length=128)
|
||||
feature_title: str = Field(..., min_length=1)
|
||||
body: str = Field(..., min_length=1)
|
||||
feature_slug: str = Field(default="", max_length=128)
|
||||
feature_title: str = ""
|
||||
body: str = ""
|
||||
wants_video: bool = False
|
||||
video_script: str = ""
|
||||
skip: bool = False
|
||||
skip_reason: str = ""
|
||||
|
||||
|
||||
class ProposeVideoRequest(BaseModel):
|
||||
|
||||
+5
-3
@@ -953,9 +953,11 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
x_feature_spotlight_interval_seconds: int = Field(
|
||||
default=259200, # 3 days — tunable; marketing cadence is a CEO call, not a
|
||||
# technical constant. Sits between the 30-min mentions poll
|
||||
# and the weekly roadmap cycle.
|
||||
default=86400, # 1 day — tunable; marketing cadence is a CEO call, not a
|
||||
# technical constant. This is the BASE loop tick only: the engine's own
|
||||
# smart-cadence guard (XEngine._feature_activity_stretch_skip) stretches
|
||||
# the effective cadence to 3x this whenever nothing has shipped since
|
||||
# the last spotlight activity, so a quiet week doesn't fire daily.
|
||||
ge=3600,
|
||||
description="Seconds between feature-spotlight exploration cycles.",
|
||||
)
|
||||
|
||||
@@ -41,6 +41,8 @@ X_REJECT_REASON = "x_reject_reason"
|
||||
X_POSTED_TWEET_ID = "x_posted_tweet_id"
|
||||
X_FEATURE_REF = "x_feature_ref"
|
||||
X_SEEN_FEATURES = "x_seen_features"
|
||||
X_SPOTLIGHT_BRIEF = "x_spotlight_brief"
|
||||
X_SPOTLIGHT_SKIP_REASON = "x_spotlight_skip_reason"
|
||||
ROADMAP_CYCLE = "roadmap_cycle"
|
||||
VIDEO_DRAFT = "video_draft"
|
||||
VIDEO_REJECT_REASON = "video_reject_reason"
|
||||
@@ -208,6 +210,24 @@ def set_x_seen_features(task: HasMarkers, slugs: list[str]) -> None:
|
||||
set_marker(task, X_SEEN_FEATURES, [str(s) for s in slugs])
|
||||
|
||||
|
||||
def get_x_spotlight_brief(task: HasMarkers) -> dict[str, Any] | None:
|
||||
val = get_marker(task, X_SPOTLIGHT_BRIEF)
|
||||
return val if isinstance(val, dict) else None
|
||||
|
||||
|
||||
def set_x_spotlight_brief(task: HasMarkers, brief: dict[str, Any]) -> None:
|
||||
set_marker(task, X_SPOTLIGHT_BRIEF, brief)
|
||||
|
||||
|
||||
def get_x_spotlight_skip_reason(task: HasMarkers) -> str | None:
|
||||
val = get_marker(task, X_SPOTLIGHT_SKIP_REASON)
|
||||
return str(val) if val else None
|
||||
|
||||
|
||||
def set_x_spotlight_skip_reason(task: HasMarkers, reason: str) -> None:
|
||||
set_marker(task, X_SPOTLIGHT_SKIP_REASON, reason)
|
||||
|
||||
|
||||
# --- board roadmap cycle ---------------------------------------------------
|
||||
# The themed cycle (goal + item drafts) the Product Owner authors via
|
||||
# ``propose_roadmap`` onto the exploration task the roadmap engine opened.
|
||||
|
||||
+24
-6
@@ -586,27 +586,43 @@ def propose_roadmap(cycle_goal: str, items: list[dict[str, Any]]) -> dict[str, A
|
||||
|
||||
|
||||
def propose_feature_spotlight(
|
||||
feature_slug: str,
|
||||
feature_title: str,
|
||||
body: str,
|
||||
feature_slug: str = "",
|
||||
feature_title: str = "",
|
||||
body: str = "",
|
||||
wants_video: bool = False,
|
||||
video_script: str = "",
|
||||
skip: bool = False,
|
||||
skip_reason: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Head of Marketing: draft ONE feature-spotlight marketing post.
|
||||
"""Head of Marketing: draft ONE feature-spotlight marketing post, or skip.
|
||||
|
||||
Call this exactly ONCE per exploration cycle, after investigating the
|
||||
CHANGELOG, feature-flags ledger, docs/map, charter, and KB to pick a real,
|
||||
under-publicized capability. The draft is held in the X post queue for the
|
||||
CEO to edit/approve — nothing auto-posts.
|
||||
|
||||
If nothing shipped is genuinely worth spotlighting this cycle, pass
|
||||
skip=True with a substantive skip_reason instead of forcing a weak post —
|
||||
a forced spotlight is worse than skipping. A skip still completes the
|
||||
exploration task (no draft materialized, no feature marked seen) and
|
||||
counts as this cycle's activity for the engine's cadence, so it won't just
|
||||
re-fire daily into the same quiet period.
|
||||
|
||||
Args:
|
||||
feature_slug: Stable slug identifying the feature (the dedup key).
|
||||
feature_title: Short human title of the feature.
|
||||
body: The tweet text (plain, <=280 chars, no invented facts).
|
||||
Ignored when skip=True.
|
||||
feature_title: Short human title of the feature. Ignored when
|
||||
skip=True.
|
||||
body: The tweet text (plain, <=280 chars, no invented facts). Ignored
|
||||
when skip=True.
|
||||
wants_video: Also request a companion video (held separately for CEO
|
||||
approval, when the video engine is armed for spotlights).
|
||||
video_script: Optional script for that video; falls back to the
|
||||
feature title/body when omitted.
|
||||
skip: True to declare "nothing worth spotlighting this cycle" instead
|
||||
of authoring a draft.
|
||||
skip_reason: Required (non-empty, >=8 chars) explanation when
|
||||
skip=True.
|
||||
"""
|
||||
return _post(
|
||||
"/api/v1/do/propose_feature_spotlight",
|
||||
@@ -616,6 +632,8 @@ def propose_feature_spotlight(
|
||||
"body": body,
|
||||
"wants_video": wants_video,
|
||||
"video_script": video_script,
|
||||
"skip": skip,
|
||||
"skip_reason": skip_reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -799,6 +799,53 @@ def _is_non_dev_dispatch_source(task: dict[str, Any]) -> bool:
|
||||
_MAX_VIDEO_RENDER_ATTEMPTS = _markers.MAX_VIDEO_RENDER_ATTEMPTS
|
||||
|
||||
|
||||
def _format_seen_features(markers_dict: dict[str, Any]) -> str:
|
||||
"""Render the seen-features ledger with dates when the enriched
|
||||
``x_spotlight_brief`` marker carries them, falling back to the plain
|
||||
slug list (a pre-brief exploration, or a brief-gather failure) — the
|
||||
prompt must never break on a missing/partial marker. Module-level (not a
|
||||
method), mirroring ``_is_non_dev_dispatch_source``, so this is unit
|
||||
testable without a wholesale-mocked ``self`` (xenon budget: keeps
|
||||
``_build_feature_spotlight_prompt`` a flat render instead of inlining
|
||||
this branching)."""
|
||||
brief = markers_dict.get(_markers.X_SPOTLIGHT_BRIEF) or {}
|
||||
seen = brief.get("seen")
|
||||
if isinstance(seen, list) and seen:
|
||||
return ", ".join(
|
||||
f"{s.get('slug')} (seen {str(s.get('seen_at') or '')[:10]})" for s in seen
|
||||
)
|
||||
slugs = markers_dict.get(_markers.X_SEEN_FEATURES) or []
|
||||
return ", ".join(slugs) if slugs else "(none yet — this is the first cycle)"
|
||||
|
||||
|
||||
def _format_shipped_since(markers_dict: dict[str, Any]) -> str:
|
||||
"""Render the CHANGELOG sections shipped since the last spotlight
|
||||
activity — empty/missing brief renders as "nothing new" rather than
|
||||
breaking the prompt (see ``_format_seen_features``)."""
|
||||
brief = markers_dict.get(_markers.X_SPOTLIGHT_BRIEF) or {}
|
||||
shipped = brief.get("shipped_since")
|
||||
if not isinstance(shipped, list) or not shipped:
|
||||
return "(nothing new since the last cycle, per CHANGELOG.md)"
|
||||
parts = [
|
||||
f"v{entry.get('version')} ({entry.get('date')}): "
|
||||
f"{', '.join(entry.get('titles') or []) or 'no subsections'}"
|
||||
for entry in shipped
|
||||
]
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _format_rejected_spotlights(markers_dict: dict[str, Any]) -> str:
|
||||
"""Render recently CEO-rejected x_feature drafts + their reasons, so HoM
|
||||
steers away from ground the CEO already turned down."""
|
||||
brief = markers_dict.get(_markers.X_SPOTLIGHT_BRIEF) or {}
|
||||
rejected = brief.get("rejected")
|
||||
if not isinstance(rejected, list) or not rejected:
|
||||
return "(none)"
|
||||
return "; ".join(
|
||||
f"{r.get('title') or r.get('slug')} — {r.get('reason')}" for r in rejected
|
||||
)
|
||||
|
||||
|
||||
class AgentOrchestrator:
|
||||
"""
|
||||
Manages Claude Code containers for all agents.
|
||||
@@ -13657,8 +13704,9 @@ that is not your job here, and the gateway will reject those verbs.
|
||||
"""Prompt for the Head of Marketing's one-shot feature-spotlight cycle."""
|
||||
task_id = task.get("id", "unknown")
|
||||
markers_dict = task.get("orchestration_markers") or {}
|
||||
seen = markers_dict.get(_markers.X_SEEN_FEATURES) or []
|
||||
seen_line = ", ".join(seen) if seen else "(none yet — this is the first cycle)"
|
||||
seen_line = _format_seen_features(markers_dict)
|
||||
shipped_line = _format_shipped_since(markers_dict)
|
||||
rejected_line = _format_rejected_spotlights(markers_dict)
|
||||
return f"""\
|
||||
You are the Head of Marketing. It's time for your periodic feature-spotlight cycle.
|
||||
|
||||
@@ -13667,10 +13715,15 @@ TASK: {task_id}
|
||||
RoboCo markets its own capabilities, not just releases. Investigate what the
|
||||
company has actually shipped and draft ONE marketing post about a genuinely
|
||||
useful, under-publicized capability — something a user or prospect would not
|
||||
already know from the last release announcement.
|
||||
already know from the last release announcement. Prefer something fresh but
|
||||
not yet spotlighted over stale already-covered ground.
|
||||
|
||||
ALREADY COVERED — do not repeat: {seen_line}
|
||||
|
||||
SHIPPED SINCE THE LAST CYCLE (CHANGELOG.md): {shipped_line}
|
||||
|
||||
RECENTLY REJECTED BY THE CEO — avoid repeating these angles: {rejected_line}
|
||||
|
||||
== WHAT TO DO ==
|
||||
|
||||
1. triage() — see your board-level context.
|
||||
@@ -13690,9 +13743,15 @@ ALREADY COVERED — do not repeat: {seen_line}
|
||||
5. propose_feature_spotlight(feature_slug="<a short stable slug>",
|
||||
feature_title="<human-readable feature name>", body="<the post>")
|
||||
— call this EXACTLY ONCE.
|
||||
6. i_am_idle() — once proposed. The CEO reviews, edits, approves, or rejects
|
||||
the draft in the X post queue; nothing posts without that explicit
|
||||
approval.
|
||||
|
||||
If nothing shipped is genuinely worth spotlighting this cycle, call
|
||||
propose_feature_spotlight(skip=True, skip_reason="<why nothing qualifies>")
|
||||
instead — a weak, forced spotlight is worse than skipping a cycle, and the
|
||||
next cycle will see this skip as recent activity (the cadence won't just
|
||||
re-fire into the same quiet period tomorrow).
|
||||
6. i_am_idle() — once proposed (or skipped). The CEO reviews, edits, approves,
|
||||
or rejects the draft in the X post queue; nothing posts without that
|
||||
explicit approval.
|
||||
|
||||
Do NOT claim, plan, delegate, or attempt to post anything yourself — that is
|
||||
not your job here, and the gateway will reject those.
|
||||
|
||||
@@ -1297,22 +1297,67 @@ class ContentActions:
|
||||
)
|
||||
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,
|
||||
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.
|
||||
"""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
|
||||
@@ -1332,6 +1377,8 @@ class ContentActions:
|
||||
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
|
||||
):
|
||||
|
||||
@@ -1588,6 +1588,23 @@ class TaskService(BaseService):
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_open_feature_spotlight_drafts(self) -> list[TaskTable]:
|
||||
"""Non-terminal MATERIALIZED spotlight drafts only (source=x_feature,
|
||||
excludes the exploration source) — the "never stack a second draft"
|
||||
basis for XEngine.open_feature_spotlight_exploration. Distinct from
|
||||
list_open_x_posts' shared numeric cap: this is a one-open-draft rule
|
||||
specific to the spotlight source, mirroring list_open_video_post_drafts'
|
||||
narrower filter over list_open_video_posts. Ordered oldest-first."""
|
||||
result = await self.session.execute(
|
||||
select(TaskTable)
|
||||
.where(
|
||||
TaskTable.source == X_FEATURE_SOURCE,
|
||||
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
|
||||
)
|
||||
.order_by(TaskTable.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_external_pr_reviews_awaiting_decision(self) -> list[TaskTable]:
|
||||
"""Completed external-PR reviews still awaiting the CEO's decision.
|
||||
|
||||
|
||||
+274
-14
@@ -21,16 +21,19 @@ from ``ReleaseProposalService.approve()``'s publish success branch;
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as redis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import (
|
||||
AgentSpawnSessionTable,
|
||||
TaskTable,
|
||||
XSeenFeatureTable,
|
||||
XSeenMentionTable,
|
||||
)
|
||||
@@ -61,7 +64,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import ProjectTable, TaskTable
|
||||
from roboco.db.tables import ProjectTable
|
||||
from roboco.services.task import TaskService
|
||||
|
||||
_CHAT_TIMEOUT_SECONDS = 60.0
|
||||
@@ -147,11 +150,65 @@ _FEATURE_EXPLORATION_TITLE = "X feature-spotlight exploration"
|
||||
_FEATURE_EXPLORATION_DESCRIPTION = (
|
||||
"Investigate RoboCo's own shipped capabilities — CHANGELOG.md, the "
|
||||
"feature-flags ledger, docs/map, the company charter, and the knowledge "
|
||||
"base — and pick ONE under-publicized feature not already covered (see "
|
||||
"the seen-features list on this task). Draft ONE marketing post via "
|
||||
"propose_feature_spotlight()."
|
||||
"base — and pick ONE under-publicized, fresh-but-unspotlighted feature "
|
||||
"not already covered (see the seen-features list on this task). Draft "
|
||||
"ONE marketing post via propose_feature_spotlight(). If nothing shipped "
|
||||
"is genuinely worth spotlighting this cycle, call "
|
||||
"propose_feature_spotlight(skip=True, skip_reason='<why>') instead — a "
|
||||
"weak, forced spotlight is worse than skipping a cycle; the skip still "
|
||||
"counts as this cycle's activity so the engine doesn't re-fire daily "
|
||||
"into the same quiet period."
|
||||
)
|
||||
|
||||
# --- CHANGELOG.md parsing (activity-stretch signal + brief enrichment) -----
|
||||
# Keep-a-Changelog headers are regular enough for a small regex split instead
|
||||
# of a markdown-parser dependency: "## [X.Y.Z] - YYYY-MM-DD" release headers,
|
||||
# "### Added/Fixed/Changed/..." subsection headers within each release body.
|
||||
_CHANGELOG_VERSION_RE = re.compile(
|
||||
r"^## \[(?P<version>[^\]]+)\] - (?P<date>\d{4}-\d{2}-\d{2})\s*$", re.MULTILINE
|
||||
)
|
||||
_CHANGELOG_SUBSECTION_RE = re.compile(r"^### (?P<title>.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _parse_changelog_sections(text: str) -> list[dict[str, Any]]:
|
||||
"""Split a Keep-a-Changelog file into per-release sections: version, date
|
||||
(the file's own day granularity), and subsection titles. Pure + best-
|
||||
effort — a malformed/missing header just yields fewer/no sections, never
|
||||
raises, so a hand-edited CHANGELOG can't break the engine."""
|
||||
headers = list(_CHANGELOG_VERSION_RE.finditer(text))
|
||||
sections: list[dict[str, Any]] = []
|
||||
for i, m in enumerate(headers):
|
||||
start = m.end()
|
||||
end = headers[i + 1].start() if i + 1 < len(headers) else len(text)
|
||||
titles = [t.strip() for t in _CHANGELOG_SUBSECTION_RE.findall(text[start:end])]
|
||||
sections.append(
|
||||
{"version": m.group("version"), "date": m.group("date"), "titles": titles}
|
||||
)
|
||||
return sections
|
||||
|
||||
|
||||
def _sections_since(
|
||||
sections: list[dict[str, Any]], cutoff: datetime
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Sections dated strictly after ``cutoff``'s calendar date.
|
||||
|
||||
The CHANGELOG only carries day granularity, so a section dated the same
|
||||
day as an intra-day cutoff can't be ordered against it and is
|
||||
conservatively treated as not-new (a false "nothing shipped" costs one
|
||||
extra quiet cycle; a false "something shipped" would let a weak forced
|
||||
spotlight through — the former is the cheaper mistake).
|
||||
"""
|
||||
cutoff_date = cutoff.date()
|
||||
out: list[dict[str, Any]] = []
|
||||
for section in sections:
|
||||
try:
|
||||
sec_date = datetime.strptime(section["date"], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
if sec_date > cutoff_date:
|
||||
out.append(section)
|
||||
return out
|
||||
|
||||
|
||||
class XEngine(BaseService):
|
||||
"""Draft release posts (event hook) + mention replies (poll), both held."""
|
||||
@@ -383,14 +440,15 @@ class XEngine(BaseService):
|
||||
|
||||
No-ops when the flags are off, no X credentials are configured (drafting
|
||||
content nobody can ever post is pointless — mirrors the release/mentions
|
||||
guard), a cycle is already open, the shared open-post cap is reached, or
|
||||
the RoboCo project isn't resolvable. Never authors content itself — HoM
|
||||
does, via propose_feature_spotlight() once the dispatcher spawns it.
|
||||
guard), a materialized spotlight draft is still awaiting the CEO (never
|
||||
stack a second one), nothing has shipped since the last spotlight
|
||||
activity and the stretched cadence hasn't elapsed yet (the "smart
|
||||
cadence" guard — see ``_feature_activity_stretch_skip``), a cycle is
|
||||
already open, the shared open-post cap is reached, or the RoboCo
|
||||
project isn't resolvable. Never authors content itself — HoM does, via
|
||||
propose_feature_spotlight() once the dispatcher spawns it.
|
||||
"""
|
||||
if not (settings.x_engine_enabled and settings.x_feature_spotlight_enabled):
|
||||
return None
|
||||
client = await self._client()
|
||||
if not client.configured:
|
||||
if not await self._feature_spotlight_may_proceed():
|
||||
return None
|
||||
task_svc = get_task_service(self.session)
|
||||
# Cancel a stale + spawnless exploration so the engine can re-arm; a
|
||||
@@ -409,6 +467,181 @@ class XEngine(BaseService):
|
||||
task_svc, cast("UUID", project.id)
|
||||
)
|
||||
|
||||
async def _feature_spotlight_may_proceed(self) -> bool:
|
||||
"""Bundles the flags/creds/pending-draft/activity-stretch guards into
|
||||
one boolean so ``open_feature_spotlight_exploration``'s own
|
||||
return-statement count stays under the xenon/PLR0911 budget — each
|
||||
sub-guard still logs its own skip reason."""
|
||||
if not (settings.x_engine_enabled and settings.x_feature_spotlight_enabled):
|
||||
return False
|
||||
client = await self._client()
|
||||
if not client.configured:
|
||||
return False
|
||||
task_svc = get_task_service(self.session)
|
||||
if await self._pending_spotlight_draft_open(task_svc):
|
||||
return False
|
||||
return not await self._feature_activity_stretch_skip()
|
||||
|
||||
async def _pending_spotlight_draft_open(self, task_svc: TaskService) -> bool:
|
||||
"""True when a materialized x_feature draft is still open (PENDING,
|
||||
awaiting the CEO in the X post queue) — never stack a second spotlight
|
||||
draft while one is unreviewed. Distinct from the shared numeric
|
||||
open-post cap below: this is a one-open-draft rule specific to the
|
||||
spotlight source."""
|
||||
drafts = await task_svc.list_open_feature_spotlight_drafts()
|
||||
if not drafts:
|
||||
return False
|
||||
self.log.info(
|
||||
"x-engine: feature-spotlight draft still open; skipping this cycle",
|
||||
open_draft_id=str(drafts[0].id),
|
||||
)
|
||||
return True
|
||||
|
||||
async def _feature_activity_stretch_skip(self) -> bool:
|
||||
"""True to skip this cycle under the smart-cadence guard.
|
||||
|
||||
Stretches the effective cadence to 3x the base interval whenever
|
||||
nothing has shipped (per CHANGELOG.md) since the last spotlight
|
||||
activity, so a quiet stretch doesn't fire the Head of Marketing daily
|
||||
into nothing. Always False on no activity history yet (a first-ever
|
||||
cycle is never stretched) or on a changelog-read failure (fail open —
|
||||
a signal outage must never silently starve the engine of cycles).
|
||||
"""
|
||||
last_activity = await self._last_spotlight_activity()
|
||||
if last_activity is None:
|
||||
return False
|
||||
try:
|
||||
shipped = bool(await self._shipped_sections_since(last_activity))
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"x-engine: changelog activity check failed (fail open)",
|
||||
error=str(exc),
|
||||
)
|
||||
return False
|
||||
if shipped:
|
||||
return False
|
||||
stretched_seconds = 3 * settings.x_feature_spotlight_interval_seconds
|
||||
quiet = (datetime.now(UTC) - last_activity).total_seconds() < stretched_seconds
|
||||
if quiet:
|
||||
self.log.info(
|
||||
"x-engine: feature-spotlight cadence stretched "
|
||||
"(nothing shipped since last activity)",
|
||||
last_activity=last_activity.isoformat(),
|
||||
stretched_seconds=stretched_seconds,
|
||||
)
|
||||
return quiet
|
||||
|
||||
async def _last_spotlight_activity(self) -> datetime | None:
|
||||
"""Latest genuine spotlight-cycle activity, or None with no history yet.
|
||||
|
||||
Two sources, taking the max: a materialized draft's ``seen_at``
|
||||
(XSeenFeatureTable), or a COMPLETED exploration task's ``updated_at``
|
||||
— a HoM "skip" verdict completes the exploration with no
|
||||
seen-features row, so ``updated_at`` is the only signal that advances
|
||||
on a skip (mirrors ``list_x_post_history``'s use of ``updated_at`` as
|
||||
"when this was acted on"). Deliberately excludes CANCELLED
|
||||
explorations: the stale-cycle janitor (``_cancel_stale_exploration``)
|
||||
cancels an abandoned cycle with no HoM action at all, which must not
|
||||
look like activity or the stretch guard would wrongly suppress the
|
||||
very next real cycle.
|
||||
"""
|
||||
seen_max = (
|
||||
await self.session.execute(select(func.max(XSeenFeatureTable.seen_at)))
|
||||
).scalar_one_or_none()
|
||||
explo_max = (
|
||||
await self.session.execute(
|
||||
select(func.max(TaskTable.updated_at)).where(
|
||||
TaskTable.source == X_FEATURE_EXPLORATION_SOURCE,
|
||||
TaskTable.status == TaskStatus.COMPLETED,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
candidates = [v for v in (seen_max, explo_max) if v is not None]
|
||||
return max(candidates) if candidates else None
|
||||
|
||||
async def _shipped_sections_since(self, cutoff: datetime) -> list[dict[str, Any]]:
|
||||
"""CHANGELOG.md release sections dated after ``cutoff`` — the shared
|
||||
"did anything ship" signal for both the activity-stretch gate and the
|
||||
exploration brief (``_gather_spotlight_brief``).
|
||||
|
||||
Reads the RoboCo project's read clone (``WorkspaceService.
|
||||
ensure_read_clone``) rather than running a full
|
||||
``ReleaseReadinessService.assess``: a single small-file read + regex
|
||||
split is far cheaper than the multi-subprocess git snapshot the
|
||||
release manager needs, and this runs on every feature-spotlight loop
|
||||
tick (up to daily), not once per release.
|
||||
"""
|
||||
from roboco.services.release_readiness import _read_changelog
|
||||
from roboco.services.workspace import get_workspace_service
|
||||
|
||||
slug = (settings.self_heal_project_slug or "roboco-api").strip()
|
||||
root = await get_workspace_service(self.session).ensure_read_clone(slug)
|
||||
text = _read_changelog(Path(root))
|
||||
return _sections_since(_parse_changelog_sections(text), cutoff)
|
||||
|
||||
async def _recent_rejected_spotlights(
|
||||
self, *, limit: int = 5
|
||||
) -> list[dict[str, str]]:
|
||||
"""Recently CEO-rejected x_feature drafts + their reject reasons, so
|
||||
HoM doesn't re-propose ground the CEO already turned down. Reuses
|
||||
``list_x_post_history`` (both sources + terminal statuses) and
|
||||
filters in Python — a rejected-spotlight-only query isn't worth a
|
||||
dedicated task-service method for a bounded top-N read."""
|
||||
history = await get_task_service(self.session).list_x_post_history(limit=50)
|
||||
rejected: list[dict[str, str]] = []
|
||||
for t in history:
|
||||
if t.source != X_FEATURE_SOURCE or t.status != TaskStatus.CANCELLED:
|
||||
continue
|
||||
reason = markers.get_x_reject_reason(t)
|
||||
if not reason:
|
||||
continue
|
||||
ref = markers.get_x_feature_ref(t) or {}
|
||||
rejected.append(
|
||||
{
|
||||
"slug": str(ref.get("slug", "")),
|
||||
"title": str(ref.get("title", "")),
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
if len(rejected) >= limit:
|
||||
break
|
||||
return rejected
|
||||
|
||||
async def _gather_spotlight_brief(self) -> dict[str, Any]:
|
||||
"""Everything the spawn prompt's brief-enrichment needs beyond the
|
||||
plain seen-slugs list: the seen ledger WITH dates, what shipped since
|
||||
the last spotlight activity (same changelog signal as the
|
||||
activity-stretch gate), and recently CEO-rejected x_feature drafts
|
||||
with their reasons — so HoM can prefer fresh-but-unspotlighted
|
||||
material over stale or already-rejected ground. Every piece is
|
||||
best-effort; a changelog-read failure yields an empty
|
||||
``shipped_since`` rather than blocking origination.
|
||||
"""
|
||||
seen_rows = (
|
||||
await self.session.execute(
|
||||
select(XSeenFeatureTable).order_by(XSeenFeatureTable.seen_at)
|
||||
)
|
||||
).scalars()
|
||||
seen = [
|
||||
{"slug": row.feature_slug, "seen_at": row.seen_at.isoformat()}
|
||||
for row in seen_rows
|
||||
]
|
||||
last_activity = await self._last_spotlight_activity()
|
||||
shipped_since: list[dict[str, Any]] = []
|
||||
if last_activity is not None:
|
||||
try:
|
||||
shipped_since = await self._shipped_sections_since(last_activity)
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"x-engine: changelog brief read failed (best-effort)",
|
||||
error=str(exc),
|
||||
)
|
||||
return {
|
||||
"seen": seen,
|
||||
"shipped_since": shipped_since,
|
||||
"rejected": await self._recent_rejected_spotlights(),
|
||||
}
|
||||
|
||||
async def _cancel_stale_exploration(self, task_svc: TaskService) -> bool:
|
||||
"""Return False to block re-arm (fresh cycle or live HoM spawn); return
|
||||
True to proceed — either no open exploration, or a stale+spawnless one
|
||||
@@ -451,13 +684,16 @@ class XEngine(BaseService):
|
||||
self, task_svc: TaskService, project_id: UUID
|
||||
) -> TaskTable:
|
||||
seen = await self._seen_feature_slugs()
|
||||
brief = await self._gather_spotlight_brief()
|
||||
task = await task_svc.create(
|
||||
TaskCreateRequest(
|
||||
title=_FEATURE_EXPLORATION_TITLE,
|
||||
description=_FEATURE_EXPLORATION_DESCRIPTION,
|
||||
acceptance_criteria=[
|
||||
"propose_feature_spotlight() is called once with an "
|
||||
"under-publicized, not-yet-covered feature"
|
||||
"under-publicized, not-yet-covered feature, OR skip=True "
|
||||
"with a substantive skip_reason when nothing is worth "
|
||||
"spotlighting this cycle"
|
||||
],
|
||||
team=Team.BOARD,
|
||||
assigned_to=_foundation.AGENTS["head-marketing"].uuid,
|
||||
@@ -472,6 +708,7 @@ class XEngine(BaseService):
|
||||
)
|
||||
)
|
||||
markers.set_x_seen_features(task, seen)
|
||||
markers.set_x_spotlight_brief(task, brief)
|
||||
await self.session.flush()
|
||||
self.log.info(
|
||||
"feature-spotlight exploration opened (Head of Marketing)",
|
||||
@@ -486,6 +723,29 @@ class XEngine(BaseService):
|
||||
async def is_feature_seen(self, feature_slug: str) -> bool:
|
||||
return await self.session.get(XSeenFeatureTable, feature_slug) is not None
|
||||
|
||||
async def skip_feature_spotlight(
|
||||
self, *, exploration_task: TaskTable, reason: str
|
||||
) -> TaskTable:
|
||||
"""Complete a HoM "nothing worth spotlighting this cycle" verdict.
|
||||
|
||||
No draft is materialized and no feature slug is marked seen (a skip
|
||||
covers nothing). The exploration task completes exactly like a
|
||||
materialized spotlight does, so its ``updated_at`` feeds
|
||||
``_last_spotlight_activity`` — a skip counts as this cycle's activity,
|
||||
which is what keeps the smart-cadence guard from re-firing daily into
|
||||
the same quiet period. Called only from the propose_feature_spotlight
|
||||
content verb's skip=True branch.
|
||||
"""
|
||||
markers.set_x_spotlight_skip_reason(exploration_task, reason)
|
||||
exploration_task.status = TaskStatus.COMPLETED
|
||||
await self.session.flush()
|
||||
self.log.info(
|
||||
"x-engine: feature spotlight skipped (nothing to spotlight)",
|
||||
task_id=str(exploration_task.id),
|
||||
reason=reason,
|
||||
)
|
||||
return exploration_task
|
||||
|
||||
# ---- shared origination -------------------------------------------------
|
||||
|
||||
async def _originate_post(
|
||||
|
||||
@@ -10,6 +10,7 @@ from roboco.services.settings import FEATURE_FLAGS, validate_setting
|
||||
|
||||
_DEFAULT_INTERVAL = 1800
|
||||
_DEFAULT_MAX_OPEN = 10
|
||||
_DEFAULT_SPOTLIGHT_INTERVAL_SECONDS = 86400 # 1 day
|
||||
|
||||
|
||||
def test_x_engine_disabled_by_default() -> None:
|
||||
@@ -46,3 +47,13 @@ def test_x_feature_spotlight_disabled_by_default() -> None:
|
||||
|
||||
def test_x_feature_spotlight_flag_registered_in_feature_flags() -> None:
|
||||
assert "x_feature_spotlight_enabled" in [key for key, _ in FEATURE_FLAGS]
|
||||
|
||||
|
||||
def test_x_feature_spotlight_interval_defaults_to_one_day() -> None:
|
||||
"""CEO directive: default cadence is 1 day (was 3 days) — the engine's
|
||||
own smart-cadence guard stretches this to 3x when quiet instead of the
|
||||
interval itself defaulting slower."""
|
||||
assert (
|
||||
Settings().x_feature_spotlight_interval_seconds
|
||||
== _DEFAULT_SPOTLIGHT_INTERVAL_SECONDS
|
||||
)
|
||||
|
||||
@@ -300,3 +300,81 @@ async def test_propose_feature_spotlight_default_wants_video_false_leaves_marker
|
||||
assert env.error is None
|
||||
assert env.status == "feature_spotlight_proposed"
|
||||
assert markers.get_x_feature_ref(materialized) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# skip — the HoM "nothing worth spotlighting this cycle" exit
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_skip_forbidden_for_product_owner() -> None:
|
||||
env = await _actions("product_owner").propose_feature_spotlight(
|
||||
agent_id=uuid4(), skip=True, skip_reason="nothing shipped worth spotlighting"
|
||||
)
|
||||
assert env.error == "not_authorized"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_skip_requires_reason() -> None:
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), skip=True
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_skip_rejects_short_reason() -> None:
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), skip=True, skip_reason="meh"
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_skip_no_open_exploration_is_invalid_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_feature_explorations = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=uuid4(), skip=True, skip_reason="nothing shipped worth spotlighting"
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_skip_completes_exploration_without_draft(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
agent_id = uuid4()
|
||||
exploration = _FakeTask(assigned_to=agent_id)
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.skip_feature_spotlight = AsyncMock(return_value=exploration)
|
||||
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: engine)
|
||||
|
||||
reason = "nothing shipped worth spotlighting this cycle"
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(
|
||||
agent_id=agent_id, skip=True, skip_reason=reason
|
||||
)
|
||||
|
||||
assert env.error is None
|
||||
assert env.status == "feature_spotlight_skipped"
|
||||
assert env.task_id == str(exploration.id)
|
||||
engine.skip_feature_spotlight.assert_awaited_once_with(
|
||||
exploration_task=exploration, reason=reason
|
||||
)
|
||||
engine.materialize_feature_spotlight.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_feature_spotlight_missing_fields_is_invalid_state() -> None:
|
||||
"""Defaulting feature_slug/feature_title/body to "" (so skip=True never
|
||||
forces dummy values) must not weaken non-skip validation."""
|
||||
env = await _actions("head_marketing").propose_feature_spotlight(agent_id=uuid4())
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
@@ -142,3 +142,50 @@ def test_feature_spotlight_prompt_names_real_verbs_and_seen_features() -> None:
|
||||
|
||||
empty_prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
||||
assert "none yet" in empty_prompt.lower()
|
||||
|
||||
|
||||
def test_feature_spotlight_prompt_names_the_skip_exit() -> None:
|
||||
"""The HoM must know the skip=True exit exists and that it beats a weak
|
||||
forced spotlight — otherwise the smart-cadence work is pointless."""
|
||||
orch = _make_orch()
|
||||
prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
||||
assert "skip=True" in prompt
|
||||
assert "skip_reason=" in prompt
|
||||
|
||||
|
||||
def test_feature_spotlight_prompt_renders_enriched_brief_sections() -> None:
|
||||
"""The x_spotlight_brief marker's seen-dates, shipped-since, and rejected
|
||||
sections must reach the spawn prompt HoM actually sees."""
|
||||
orch = _make_orch()
|
||||
markers_dict = {
|
||||
"x_seen_features": ["org-memory"],
|
||||
"x_spotlight_brief": {
|
||||
"seen": [{"slug": "org-memory", "seen_at": "2026-07-01T00:00:00+00:00"}],
|
||||
"shipped_since": [
|
||||
{
|
||||
"version": "0.21.0",
|
||||
"date": "2026-07-09",
|
||||
"titles": ["Added", "Fixed"],
|
||||
}
|
||||
],
|
||||
"rejected": [
|
||||
{"slug": "old-one", "title": "Old Feature", "reason": "too niche"}
|
||||
],
|
||||
},
|
||||
}
|
||||
prompt = orch._build_feature_spotlight_prompt(
|
||||
_feature_task(orchestration_markers=markers_dict)
|
||||
)
|
||||
assert "org-memory (seen 2026-07-01)" in prompt
|
||||
assert "0.21.0" in prompt
|
||||
assert "Added, Fixed" in prompt
|
||||
assert "too niche" in prompt
|
||||
|
||||
|
||||
def test_feature_spotlight_prompt_brief_fallbacks_when_marker_missing() -> None:
|
||||
"""No x_spotlight_brief marker (a pre-brief exploration) must never break
|
||||
prompt rendering — every section falls back to a friendly placeholder."""
|
||||
orch = _make_orch()
|
||||
prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
||||
assert "nothing new since the last cycle" in prompt
|
||||
assert "(none)" in prompt
|
||||
|
||||
@@ -791,6 +791,13 @@ async def test_feature_spotlight_exploration_carries_seen_features_marker(
|
||||
await db_session.flush()
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
# These seen rows are unrelated dedup fixtures, not a real "recent
|
||||
# activity" signal for the smart-cadence guard — bypass it here (a
|
||||
# dedicated no-network test covers the guard itself below) so this test
|
||||
# stays about the seen-features marker only.
|
||||
monkeypatch.setattr(
|
||||
engine, "_last_spotlight_activity", AsyncMock(return_value=None)
|
||||
)
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is not None
|
||||
assert set(markers.get_x_seen_features(task)) == {
|
||||
@@ -876,6 +883,350 @@ async def test_materialize_feature_spotlight_enforces_280_chars(
|
||||
assert len(body) <= MAX_TWEET_CHARS
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CHANGELOG.md parsing — pure functions, no DB/network needed
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_CHANGELOG_FIXTURE = (
|
||||
"# Changelog\n\n"
|
||||
"## [0.21.0] - 2026-07-09\n\n"
|
||||
"### Added\n\n- thing one\n\n### Fixed\n\n- thing two\n\n"
|
||||
"## [0.20.0] - 2026-07-01\n\n"
|
||||
"### Added\n\n- older thing\n"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_changelog_sections_extracts_version_date_titles() -> None:
|
||||
sections = x_engine_module._parse_changelog_sections(_CHANGELOG_FIXTURE)
|
||||
assert sections[0] == {
|
||||
"version": "0.21.0",
|
||||
"date": "2026-07-09",
|
||||
"titles": ["Added", "Fixed"],
|
||||
}
|
||||
assert sections[1] == {
|
||||
"version": "0.20.0",
|
||||
"date": "2026-07-01",
|
||||
"titles": ["Added"],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_changelog_sections_empty_text_yields_no_sections() -> None:
|
||||
assert x_engine_module._parse_changelog_sections("no headers here") == []
|
||||
|
||||
|
||||
def test_sections_since_excludes_earlier_and_includes_later() -> None:
|
||||
sections = x_engine_module._parse_changelog_sections(_CHANGELOG_FIXTURE)
|
||||
cutoff = datetime(2026, 7, 8, 12, 0, tzinfo=UTC)
|
||||
result = x_engine_module._sections_since(sections, cutoff)
|
||||
assert [s["version"] for s in result] == ["0.21.0"]
|
||||
|
||||
|
||||
def test_sections_since_same_calendar_day_as_cutoff_is_not_new() -> None:
|
||||
"""Day-granularity conservatism: a section dated the same day as the
|
||||
cutoff can't be ordered against it, so it's treated as not-new."""
|
||||
sections = [{"version": "0.21.0", "date": "2026-07-09", "titles": ["Added"]}]
|
||||
cutoff = datetime(2026, 7, 9, 1, 0, tzinfo=UTC)
|
||||
assert x_engine_module._sections_since(sections, cutoff) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Smart spotlight cadence: pending-draft guard, activity-stretch, skip verb
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def _seed_completed_exploration(
|
||||
session: AsyncSession, *, project_id: UUID, age: timedelta = timedelta(seconds=0)
|
||||
) -> TaskTable:
|
||||
"""A COMPLETED x_feature_exploration row with an explicit updated_at —
|
||||
``onupdate`` only fires on a real UPDATE, not this direct INSERT, so the
|
||||
activity timestamp must be set here rather than relying on the column
|
||||
default."""
|
||||
now = datetime.now(UTC)
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="X feature-spotlight exploration",
|
||||
description="seed",
|
||||
acceptance_criteria=["x"],
|
||||
status=TS.COMPLETED,
|
||||
task_type=TaskType.ADMINISTRATIVE,
|
||||
nature=TaskNature.NON_TECHNICAL,
|
||||
project_id=project_id,
|
||||
created_by=SYSTEM_UUID,
|
||||
assigned_to=HOM_UUID,
|
||||
team=Team.BOARD,
|
||||
source=X_FEATURE_EXPLORATION_SOURCE,
|
||||
confirmed_by_human=False,
|
||||
created_at=now - age,
|
||||
updated_at=now - age,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_pending_draft_blocks_new_cycle(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A still-open materialized x_feature draft blocks a new exploration —
|
||||
never stack a second spotlight draft while one awaits the CEO."""
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
exploration = await engine.open_feature_spotlight_exploration()
|
||||
assert exploration is not None
|
||||
await engine.materialize_feature_spotlight(
|
||||
exploration_task=exploration,
|
||||
feature_slug="org-memory",
|
||||
feature_title="Organizational Memory Loop",
|
||||
body="Did you know RoboCo agents learn from every completed task?",
|
||||
)
|
||||
second = await engine.open_feature_spotlight_exploration()
|
||||
assert second is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_new_cycle_resumes_once_draft_acted_on(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Once the CEO acts on the draft (cancelled here, mirroring reject), the
|
||||
pending-draft guard no longer blocks a fresh cycle."""
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
exploration = await engine.open_feature_spotlight_exploration()
|
||||
assert exploration is not None
|
||||
draft = await engine.materialize_feature_spotlight(
|
||||
exploration_task=exploration,
|
||||
feature_slug="org-memory",
|
||||
feature_title="Organizational Memory Loop",
|
||||
body="Did you know RoboCo agents learn from every completed task?",
|
||||
)
|
||||
draft.status = TS.CANCELLED # mirrors XPostService.reject
|
||||
await db_session.flush()
|
||||
# The activity-stretch guard is covered separately below; bypass it here
|
||||
# so this test is only about the pending-draft guard clearing.
|
||||
monkeypatch.setattr(
|
||||
engine, "_feature_activity_stretch_skip", AsyncMock(return_value=False)
|
||||
)
|
||||
second = await engine.open_feature_spotlight_exploration()
|
||||
assert second is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_activity_stretch_skips_when_quiet(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Nothing shipped since the last (recent) spotlight activity and less
|
||||
than 3x the interval has elapsed -> the cycle is skipped."""
|
||||
await _seed(db_session)
|
||||
_enable(
|
||||
monkeypatch,
|
||||
x_feature_spotlight_enabled=True,
|
||||
x_feature_spotlight_interval_seconds=3600,
|
||||
)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
project = await engine._roboco_project()
|
||||
assert project is not None and project.id is not None
|
||||
await _seed_completed_exploration(db_session, project_id=cast("UUID", project.id))
|
||||
monkeypatch.setattr(engine, "_shipped_sections_since", AsyncMock(return_value=[]))
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_activity_stretch_fires_when_something_shipped(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Even right after the last activity, a newer CHANGELOG section clears
|
||||
the stretch guard and the cycle proceeds."""
|
||||
await _seed(db_session)
|
||||
_enable(
|
||||
monkeypatch,
|
||||
x_feature_spotlight_enabled=True,
|
||||
x_feature_spotlight_interval_seconds=3600,
|
||||
)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
project = await engine._roboco_project()
|
||||
assert project is not None and project.id is not None
|
||||
await _seed_completed_exploration(db_session, project_id=cast("UUID", project.id))
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_shipped_sections_since",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{"version": "9.9.9", "date": "2099-01-01", "titles": ["Added"]}
|
||||
]
|
||||
),
|
||||
)
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_activity_stretch_fires_after_stretched_window(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Nothing shipped, but the 3x-stretched window has already elapsed since
|
||||
the last activity -> the cycle proceeds anyway (the stretch has a
|
||||
ceiling, it doesn't silence the engine forever)."""
|
||||
await _seed(db_session)
|
||||
_enable(
|
||||
monkeypatch,
|
||||
x_feature_spotlight_enabled=True,
|
||||
x_feature_spotlight_interval_seconds=10,
|
||||
)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
project = await engine._roboco_project()
|
||||
assert project is not None and project.id is not None
|
||||
await _seed_completed_exploration(
|
||||
db_session,
|
||||
project_id=cast("UUID", project.id),
|
||||
age=timedelta(seconds=100), # > 3 * 10s stretched window
|
||||
)
|
||||
monkeypatch.setattr(engine, "_shipped_sections_since", AsyncMock(return_value=[]))
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_no_activity_history_never_stretched(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""First-ever cycle (no seen rows, no completed explorations): the
|
||||
activity-stretch guard never even reads the changelog."""
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
spy = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(engine, "_shipped_sections_since", spy)
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is not None
|
||||
spy.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_activity_stretch_fails_open_on_changelog_error(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A changelog-read failure must never silently starve the engine of
|
||||
cycles — fail open (proceed) rather than skip."""
|
||||
await _seed(db_session)
|
||||
_enable(
|
||||
monkeypatch,
|
||||
x_feature_spotlight_enabled=True,
|
||||
x_feature_spotlight_interval_seconds=3600,
|
||||
)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
project = await engine._roboco_project()
|
||||
assert project is not None and project.id is not None
|
||||
await _seed_completed_exploration(db_session, project_id=cast("UUID", project.id))
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_shipped_sections_since",
|
||||
AsyncMock(side_effect=RuntimeError("clone failed")),
|
||||
)
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_feature_spotlight_completes_without_draft_or_seen_slug(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
exploration = await engine.open_feature_spotlight_exploration()
|
||||
assert exploration is not None
|
||||
|
||||
reason = "nothing shipped this cycle worth a spotlight"
|
||||
result = await engine.skip_feature_spotlight(
|
||||
exploration_task=exploration, reason=reason
|
||||
)
|
||||
assert result.status == TS.COMPLETED
|
||||
assert markers.get_x_spotlight_skip_reason(result) == reason
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
assert await get_task_service(db_session).list_open_x_posts() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_feature_spotlight_counts_as_activity(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A skip (no seen-features row at all) still advances
|
||||
_last_spotlight_activity via the completed exploration's updated_at."""
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
exploration = await engine.open_feature_spotlight_exploration()
|
||||
assert exploration is not None
|
||||
assert await engine._last_spotlight_activity() is None
|
||||
|
||||
await engine.skip_feature_spotlight(
|
||||
exploration_task=exploration, reason="quiet week, nothing shipped"
|
||||
)
|
||||
assert await engine._last_spotlight_activity() is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_spotlight_brief_includes_dates_shipped_and_rejected(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
db_session.add(XSeenFeatureTable(feature_slug="org-memory"))
|
||||
await db_session.flush()
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
project = await engine._roboco_project()
|
||||
assert project is not None and project.id is not None
|
||||
|
||||
rejected_task = await engine._originate_post(
|
||||
title="X post: feature spotlight — Old Feature",
|
||||
body="a previously drafted spotlight body",
|
||||
source=X_FEATURE_SOURCE,
|
||||
project_id=cast("UUID", project.id),
|
||||
)
|
||||
markers.set_x_feature_ref(
|
||||
rejected_task, {"slug": "old-one", "title": "Old Feature"}
|
||||
)
|
||||
markers.set_x_reject_reason(rejected_task, "too niche for the audience")
|
||||
rejected_task.status = TS.CANCELLED
|
||||
await db_session.flush()
|
||||
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_last_spotlight_activity",
|
||||
AsyncMock(return_value=datetime.now(UTC) - timedelta(days=1)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_shipped_sections_since",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{"version": "1.2.3", "date": "2026-07-09", "titles": ["Added"]}
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
brief = await engine._gather_spotlight_brief()
|
||||
assert brief["seen"] == [
|
||||
{"slug": "org-memory", "seen_at": brief["seen"][0]["seen_at"]}
|
||||
]
|
||||
assert brief["shipped_since"] == [
|
||||
{"version": "1.2.3", "date": "2026-07-09", "titles": ["Added"]}
|
||||
]
|
||||
assert brief["rejected"] == [
|
||||
{
|
||||
"slug": "old-one",
|
||||
"title": "Old Feature",
|
||||
"reason": "too niche for the audience",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Voice guide (feeds release/reply prompts + the HoM identity's briefing claim)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user