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)` |
|
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||||
| `read_messages` | `read_messages()` |
|
| `read_messages` | `read_messages()` |
|
||||||
| `read_a2a` | `read_a2a(see do_server)` |
|
| `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)` |
|
| `notify_ack` | `notify_ack(notification_id: UUID)` |
|
||||||
| `read_messages` | `read_messages()` |
|
| `read_messages` | `read_messages()` |
|
||||||
| `read_a2a` | `read_a2a(see do_server)` |
|
| `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
|
## auditor
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ async def do_propose_feature_spotlight(
|
|||||||
body=body.body,
|
body=body.body,
|
||||||
wants_video=body.wants_video,
|
wants_video=body.wants_video,
|
||||||
video_script=body.video_script,
|
video_script=body.video_script,
|
||||||
|
skip=body.skip,
|
||||||
|
skip_reason=body.skip_reason,
|
||||||
)
|
)
|
||||||
return envelope_to_response(env, request)
|
return envelope_to_response(env, request)
|
||||||
|
|
||||||
|
|||||||
@@ -130,13 +130,19 @@ class ProposeRoadmapRequest(BaseModel):
|
|||||||
|
|
||||||
class ProposeFeatureSpotlightRequest(BaseModel):
|
class ProposeFeatureSpotlightRequest(BaseModel):
|
||||||
"""Head of Marketing's feature-spotlight draft: a picked feature + a
|
"""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_slug: str = Field(default="", max_length=128)
|
||||||
feature_title: str = Field(..., min_length=1)
|
feature_title: str = ""
|
||||||
body: str = Field(..., min_length=1)
|
body: str = ""
|
||||||
wants_video: bool = False
|
wants_video: bool = False
|
||||||
video_script: str = ""
|
video_script: str = ""
|
||||||
|
skip: bool = False
|
||||||
|
skip_reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ProposeVideoRequest(BaseModel):
|
class ProposeVideoRequest(BaseModel):
|
||||||
|
|||||||
+5
-3
@@ -953,9 +953,11 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
x_feature_spotlight_interval_seconds: int = Field(
|
x_feature_spotlight_interval_seconds: int = Field(
|
||||||
default=259200, # 3 days — tunable; marketing cadence is a CEO call, not a
|
default=86400, # 1 day — tunable; marketing cadence is a CEO call, not a
|
||||||
# technical constant. Sits between the 30-min mentions poll
|
# technical constant. This is the BASE loop tick only: the engine's own
|
||||||
# and the weekly roadmap cycle.
|
# 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,
|
ge=3600,
|
||||||
description="Seconds between feature-spotlight exploration cycles.",
|
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_POSTED_TWEET_ID = "x_posted_tweet_id"
|
||||||
X_FEATURE_REF = "x_feature_ref"
|
X_FEATURE_REF = "x_feature_ref"
|
||||||
X_SEEN_FEATURES = "x_seen_features"
|
X_SEEN_FEATURES = "x_seen_features"
|
||||||
|
X_SPOTLIGHT_BRIEF = "x_spotlight_brief"
|
||||||
|
X_SPOTLIGHT_SKIP_REASON = "x_spotlight_skip_reason"
|
||||||
ROADMAP_CYCLE = "roadmap_cycle"
|
ROADMAP_CYCLE = "roadmap_cycle"
|
||||||
VIDEO_DRAFT = "video_draft"
|
VIDEO_DRAFT = "video_draft"
|
||||||
VIDEO_REJECT_REASON = "video_reject_reason"
|
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])
|
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 ---------------------------------------------------
|
# --- board roadmap cycle ---------------------------------------------------
|
||||||
# The themed cycle (goal + item drafts) the Product Owner authors via
|
# The themed cycle (goal + item drafts) the Product Owner authors via
|
||||||
# ``propose_roadmap`` onto the exploration task the roadmap engine opened.
|
# ``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(
|
def propose_feature_spotlight(
|
||||||
feature_slug: str,
|
feature_slug: str = "",
|
||||||
feature_title: str,
|
feature_title: str = "",
|
||||||
body: str,
|
body: str = "",
|
||||||
wants_video: bool = False,
|
wants_video: bool = False,
|
||||||
video_script: str = "",
|
video_script: str = "",
|
||||||
|
skip: bool = False,
|
||||||
|
skip_reason: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> 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
|
Call this exactly ONCE per exploration cycle, after investigating the
|
||||||
CHANGELOG, feature-flags ledger, docs/map, charter, and KB to pick a real,
|
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
|
under-publicized capability. The draft is held in the X post queue for the
|
||||||
CEO to edit/approve — nothing auto-posts.
|
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:
|
Args:
|
||||||
feature_slug: Stable slug identifying the feature (the dedup key).
|
feature_slug: Stable slug identifying the feature (the dedup key).
|
||||||
feature_title: Short human title of the feature.
|
Ignored when skip=True.
|
||||||
body: The tweet text (plain, <=280 chars, no invented facts).
|
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
|
wants_video: Also request a companion video (held separately for CEO
|
||||||
approval, when the video engine is armed for spotlights).
|
approval, when the video engine is armed for spotlights).
|
||||||
video_script: Optional script for that video; falls back to the
|
video_script: Optional script for that video; falls back to the
|
||||||
feature title/body when omitted.
|
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(
|
return _post(
|
||||||
"/api/v1/do/propose_feature_spotlight",
|
"/api/v1/do/propose_feature_spotlight",
|
||||||
@@ -616,6 +632,8 @@ def propose_feature_spotlight(
|
|||||||
"body": body,
|
"body": body,
|
||||||
"wants_video": wants_video,
|
"wants_video": wants_video,
|
||||||
"video_script": video_script,
|
"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
|
_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:
|
class AgentOrchestrator:
|
||||||
"""
|
"""
|
||||||
Manages Claude Code containers for all agents.
|
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."""
|
"""Prompt for the Head of Marketing's one-shot feature-spotlight cycle."""
|
||||||
task_id = task.get("id", "unknown")
|
task_id = task.get("id", "unknown")
|
||||||
markers_dict = task.get("orchestration_markers") or {}
|
markers_dict = task.get("orchestration_markers") or {}
|
||||||
seen = markers_dict.get(_markers.X_SEEN_FEATURES) or []
|
seen_line = _format_seen_features(markers_dict)
|
||||||
seen_line = ", ".join(seen) if seen else "(none yet — this is the first cycle)"
|
shipped_line = _format_shipped_since(markers_dict)
|
||||||
|
rejected_line = _format_rejected_spotlights(markers_dict)
|
||||||
return f"""\
|
return f"""\
|
||||||
You are the Head of Marketing. It's time for your periodic feature-spotlight cycle.
|
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
|
RoboCo markets its own capabilities, not just releases. Investigate what the
|
||||||
company has actually shipped and draft ONE marketing post about a genuinely
|
company has actually shipped and draft ONE marketing post about a genuinely
|
||||||
useful, under-publicized capability — something a user or prospect would not
|
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}
|
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 ==
|
== WHAT TO DO ==
|
||||||
|
|
||||||
1. triage() — see your board-level context.
|
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>",
|
5. propose_feature_spotlight(feature_slug="<a short stable slug>",
|
||||||
feature_title="<human-readable feature name>", body="<the post>")
|
feature_title="<human-readable feature name>", body="<the post>")
|
||||||
— call this EXACTLY ONCE.
|
— 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
|
If nothing shipped is genuinely worth spotlighting this cycle, call
|
||||||
approval.
|
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
|
Do NOT claim, plan, delegate, or attempt to post anything yourself — that is
|
||||||
not your job here, and the gateway will reject those.
|
not your job here, and the gateway will reject those.
|
||||||
|
|||||||
@@ -1297,22 +1297,67 @@ class ContentActions:
|
|||||||
)
|
)
|
||||||
return None
|
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(
|
async def propose_feature_spotlight(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
agent_id: UUID,
|
agent_id: UUID,
|
||||||
feature_slug: str,
|
feature_slug: str = "",
|
||||||
feature_title: str,
|
feature_title: str = "",
|
||||||
body: str,
|
body: str = "",
|
||||||
wants_video: bool = False,
|
wants_video: bool = False,
|
||||||
video_script: str = "",
|
video_script: str = "",
|
||||||
|
skip: bool = False,
|
||||||
|
skip_reason: str = "",
|
||||||
) -> Envelope:
|
) -> 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
|
Validates role, field lengths, the 280-char tweet limit, and that the
|
||||||
feature hasn't already been covered, then materializes the held X-queue
|
feature hasn't already been covered, then materializes the held X-queue
|
||||||
draft and completes the caller's exploration task. One call per cycle.
|
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
|
``wants_video`` optionally requests a companion video. The video
|
||||||
authoring task no longer opens here — it opens later, at CEO-approve
|
authoring task no longer opens here — it opens later, at CEO-approve
|
||||||
time (``XPostService._open_spotlight_video``, gated on
|
time (``XPostService._open_spotlight_video``, gated on
|
||||||
@@ -1332,6 +1377,8 @@ class ContentActions:
|
|||||||
remediate="this verb is Head-of-Marketing-only",
|
remediate="this verb is Head-of-Marketing-only",
|
||||||
context_briefing={},
|
context_briefing={},
|
||||||
)
|
)
|
||||||
|
if skip:
|
||||||
|
return await self._propose_feature_spotlight_skip(agent_id, skip_reason)
|
||||||
if rej := self._reject_feature_spotlight_fields(
|
if rej := self._reject_feature_spotlight_fields(
|
||||||
feature_slug, feature_title, body
|
feature_slug, feature_title, body
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1588,6 +1588,23 @@ class TaskService(BaseService):
|
|||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
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]:
|
async def list_external_pr_reviews_awaiting_decision(self) -> list[TaskTable]:
|
||||||
"""Completed external-PR reviews still awaiting the CEO's decision.
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime, timedelta
|
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 httpx
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.db.tables import (
|
from roboco.db.tables import (
|
||||||
AgentSpawnSessionTable,
|
AgentSpawnSessionTable,
|
||||||
|
TaskTable,
|
||||||
XSeenFeatureTable,
|
XSeenFeatureTable,
|
||||||
XSeenMentionTable,
|
XSeenMentionTable,
|
||||||
)
|
)
|
||||||
@@ -61,7 +64,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
from roboco.services.task import TaskService
|
||||||
|
|
||||||
_CHAT_TIMEOUT_SECONDS = 60.0
|
_CHAT_TIMEOUT_SECONDS = 60.0
|
||||||
@@ -147,11 +150,65 @@ _FEATURE_EXPLORATION_TITLE = "X feature-spotlight exploration"
|
|||||||
_FEATURE_EXPLORATION_DESCRIPTION = (
|
_FEATURE_EXPLORATION_DESCRIPTION = (
|
||||||
"Investigate RoboCo's own shipped capabilities — CHANGELOG.md, the "
|
"Investigate RoboCo's own shipped capabilities — CHANGELOG.md, the "
|
||||||
"feature-flags ledger, docs/map, the company charter, and the knowledge "
|
"feature-flags ledger, docs/map, the company charter, and the knowledge "
|
||||||
"base — and pick ONE under-publicized feature not already covered (see "
|
"base — and pick ONE under-publicized, fresh-but-unspotlighted feature "
|
||||||
"the seen-features list on this task). Draft ONE marketing post via "
|
"not already covered (see the seen-features list on this task). Draft "
|
||||||
"propose_feature_spotlight()."
|
"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):
|
class XEngine(BaseService):
|
||||||
"""Draft release posts (event hook) + mention replies (poll), both held."""
|
"""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
|
No-ops when the flags are off, no X credentials are configured (drafting
|
||||||
content nobody can ever post is pointless — mirrors the release/mentions
|
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
|
guard), a materialized spotlight draft is still awaiting the CEO (never
|
||||||
the RoboCo project isn't resolvable. Never authors content itself — HoM
|
stack a second one), nothing has shipped since the last spotlight
|
||||||
does, via propose_feature_spotlight() once the dispatcher spawns it.
|
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):
|
if not await self._feature_spotlight_may_proceed():
|
||||||
return None
|
|
||||||
client = await self._client()
|
|
||||||
if not client.configured:
|
|
||||||
return None
|
return None
|
||||||
task_svc = get_task_service(self.session)
|
task_svc = get_task_service(self.session)
|
||||||
# Cancel a stale + spawnless exploration so the engine can re-arm; a
|
# 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)
|
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:
|
async def _cancel_stale_exploration(self, task_svc: TaskService) -> bool:
|
||||||
"""Return False to block re-arm (fresh cycle or live HoM spawn); return
|
"""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
|
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
|
self, task_svc: TaskService, project_id: UUID
|
||||||
) -> TaskTable:
|
) -> TaskTable:
|
||||||
seen = await self._seen_feature_slugs()
|
seen = await self._seen_feature_slugs()
|
||||||
|
brief = await self._gather_spotlight_brief()
|
||||||
task = await task_svc.create(
|
task = await task_svc.create(
|
||||||
TaskCreateRequest(
|
TaskCreateRequest(
|
||||||
title=_FEATURE_EXPLORATION_TITLE,
|
title=_FEATURE_EXPLORATION_TITLE,
|
||||||
description=_FEATURE_EXPLORATION_DESCRIPTION,
|
description=_FEATURE_EXPLORATION_DESCRIPTION,
|
||||||
acceptance_criteria=[
|
acceptance_criteria=[
|
||||||
"propose_feature_spotlight() is called once with an "
|
"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,
|
team=Team.BOARD,
|
||||||
assigned_to=_foundation.AGENTS["head-marketing"].uuid,
|
assigned_to=_foundation.AGENTS["head-marketing"].uuid,
|
||||||
@@ -472,6 +708,7 @@ class XEngine(BaseService):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
markers.set_x_seen_features(task, seen)
|
markers.set_x_seen_features(task, seen)
|
||||||
|
markers.set_x_spotlight_brief(task, brief)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"feature-spotlight exploration opened (Head of Marketing)",
|
"feature-spotlight exploration opened (Head of Marketing)",
|
||||||
@@ -486,6 +723,29 @@ class XEngine(BaseService):
|
|||||||
async def is_feature_seen(self, feature_slug: str) -> bool:
|
async def is_feature_seen(self, feature_slug: str) -> bool:
|
||||||
return await self.session.get(XSeenFeatureTable, feature_slug) is not None
|
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 -------------------------------------------------
|
# ---- shared origination -------------------------------------------------
|
||||||
|
|
||||||
async def _originate_post(
|
async def _originate_post(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from roboco.services.settings import FEATURE_FLAGS, validate_setting
|
|||||||
|
|
||||||
_DEFAULT_INTERVAL = 1800
|
_DEFAULT_INTERVAL = 1800
|
||||||
_DEFAULT_MAX_OPEN = 10
|
_DEFAULT_MAX_OPEN = 10
|
||||||
|
_DEFAULT_SPOTLIGHT_INTERVAL_SECONDS = 86400 # 1 day
|
||||||
|
|
||||||
|
|
||||||
def test_x_engine_disabled_by_default() -> None:
|
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:
|
def test_x_feature_spotlight_flag_registered_in_feature_flags() -> None:
|
||||||
assert "x_feature_spotlight_enabled" in [key for key, _ in FEATURE_FLAGS]
|
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.error is None
|
||||||
assert env.status == "feature_spotlight_proposed"
|
assert env.status == "feature_spotlight_proposed"
|
||||||
assert markers.get_x_feature_ref(materialized) is None
|
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())
|
empty_prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
||||||
assert "none yet" in empty_prompt.lower()
|
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()
|
await db_session.flush()
|
||||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
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()
|
task = await engine.open_feature_spotlight_exploration()
|
||||||
assert task is not None
|
assert task is not None
|
||||||
assert set(markers.get_x_seen_features(task)) == {
|
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
|
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)
|
# Voice guide (feeds release/reply prompts + the HoM identity's briefing claim)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user