mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(x): redraft loop on CEO reject — feedback re-enters the draft flow (#648)
* feat(x): redraft loop on CEO reject — feedback re-enters the draft flow A rejected X draft's reason used to die with the cancel. reject() with a non-blank reason now schedules a redraft after its commit (defer_after_commit; fresh session; never blocks or fails the HTTP response): XEngine.redraft_from_rejection re-drafts the same source kind via the local model with the reason and rejected body folded in as revision guidance, originating ONE fresh held draft — mirroring the video pipeline's reauthor_from_rejection. Local-model failure or empty output originates nothing (no degraded copies); markers carry forward whole so a redrafted reply/spotlight stays fully functional downstream; bodies ride the same 280 clamp; the open-posts cap holds. Hardened per adversarial review: reject() is now idempotent on an already-CANCELLED target at both check sites (mirroring approve's already_rejected guard — a replayed reject schedules nothing), and the dedup check+originate runs under a non-blocking identity-keyed Redis lock (SET NX + compare-and-del, matching the approve/reject mutex style) so racing rejects can't stack duplicate drafts — lock held or Redis down skips the redraft, which is always safe. Tests pin the fresh-session contract by session identity, the replay no-op, the lock-skip, and clean up their own committed rows. * fix(tests): runtime UUID import + typed task-id coercion in x cleanup helper CI's quality gate runs mypy over tests/ (the local pass covered only roboco/): the _delete_tasks calls handed ORM-typed ids where uuid.UUID was expected. Coercing at the call sites then exposed that UUID was imported under TYPE_CHECKING only — a runtime NameError. Import moved to runtime; both call sites coerce explicitly. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import re
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
@@ -57,6 +58,7 @@ from roboco.services.task import (
|
|||||||
X_FEATURE_SOURCE,
|
X_FEATURE_SOURCE,
|
||||||
X_POST_SOURCE,
|
X_POST_SOURCE,
|
||||||
X_REPLY_SOURCE,
|
X_REPLY_SOURCE,
|
||||||
|
X_SOURCES,
|
||||||
TaskCreateRequest,
|
TaskCreateRequest,
|
||||||
get_task_service,
|
get_task_service,
|
||||||
)
|
)
|
||||||
@@ -83,6 +85,30 @@ _MIN_MENTION_CHARS = 3
|
|||||||
# Redis TTL guard.
|
# Redis TTL guard.
|
||||||
_BRAND_VOICE_NUDGE_KEY = "x_brand_voice_nudge_sent"
|
_BRAND_VOICE_NUDGE_KEY = "x_brand_voice_nudge_sent"
|
||||||
|
|
||||||
|
# redraft-dedup lock (see _redraft_from_rejection): closes the check-then-act
|
||||||
|
# TOCTOU on _redraft_already_open — two deferred closures racing for the SAME
|
||||||
|
# identity would otherwise both pass the dedup check (each sees nothing
|
||||||
|
# committed yet from the other under READ COMMITTED) and both originate.
|
||||||
|
# Plain SET NX + Lua compare-and-del, mirroring XPostService._acquire_lock /
|
||||||
|
# _release_lock (same style, a different key namespace) rather than a shared
|
||||||
|
# helper — this codebase duplicates this small pattern per service
|
||||||
|
# (release_proposal.py, prompter.py, x_post_service.py all carry their own).
|
||||||
|
_REDRAFT_LOCK_PREFIX = "roboco:x_redraft:"
|
||||||
|
_REDRAFT_LOCK_TTL_SECONDS = 60 # the check+insert completes in ms; crash backstop
|
||||||
|
_REDRAFT_RELEASE_SCRIPT = """
|
||||||
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("del", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _RedraftLockUnavailable(Exception):
|
||||||
|
"""Redis is unreachable for the redraft-dedup lock — distinct from "the
|
||||||
|
lock is held" (both end in the same skip-the-redraft outcome), mirroring
|
||||||
|
XPostService's _LockUnavailable."""
|
||||||
|
|
||||||
|
|
||||||
# Ported from agents/prompts/identities/head-marketing.md's VOICE GUIDE (the
|
# Ported from agents/prompts/identities/head-marketing.md's VOICE GUIDE (the
|
||||||
# reasoning-backed voice HoM's own spawns already carry) plus a slop-ban list
|
# reasoning-backed voice HoM's own spawns already carry) plus a slop-ban list
|
||||||
@@ -197,6 +223,21 @@ def _release_prompt(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_prompt(
|
||||||
|
rejected_body: str, reason: str, voice: str, product_name: str, context: str
|
||||||
|
) -> str:
|
||||||
|
extra = f"\n{context}\n" if context else ""
|
||||||
|
return (
|
||||||
|
f"{voice}\n\n"
|
||||||
|
"The CEO rejected this draft tweet with feedback below. Revise it "
|
||||||
|
"into ONE new tweet (max 280 characters) that fully addresses the "
|
||||||
|
f"feedback for {product_name} — do not just repeat the rejected "
|
||||||
|
f"wording.\n{extra}\n"
|
||||||
|
f"Rejected draft:\n{rejected_body}\n\n"
|
||||||
|
f"CEO's feedback (address every point):\n{reason}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reply_prompt(screened_mention_text: str, voice: str, product_name: str) -> str:
|
def _reply_prompt(screened_mention_text: str, voice: str, product_name: str) -> str:
|
||||||
return (
|
return (
|
||||||
f"{voice}\n\n"
|
f"{voice}\n\n"
|
||||||
@@ -1019,6 +1060,271 @@ class XEngine(BaseService):
|
|||||||
)
|
)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
# ---- reject -> redraft (CEO feedback loop) -----------------------------
|
||||||
|
|
||||||
|
async def redraft_from_rejection(
|
||||||
|
self, post_task: TaskTable, reason: str
|
||||||
|
) -> TaskTable | None:
|
||||||
|
"""Route a CEO's rejection reason into a fresh held draft of the SAME
|
||||||
|
source, mirroring ``VideoEngine.reauthor_from_rejection``.
|
||||||
|
|
||||||
|
Reads the rejected draft's source-specific reference marker (release
|
||||||
|
version for x_post, mention ref for x_reply, feature ref for
|
||||||
|
x_feature) and asks the local model to revise the rejected body with
|
||||||
|
the CEO's reason folded in as guidance. Falls back gracefully: a
|
||||||
|
local-model failure or empty draft originates NOTHING — a degraded
|
||||||
|
copy is worse than none (see ``_draft_revision_body``).
|
||||||
|
|
||||||
|
Deduped like ``VideoEngine.open_video_task``'s occasion scoping: a
|
||||||
|
redraft is skipped while one is already open for the same underlying
|
||||||
|
item (``_redraft_already_open``), so a repeated reject can't stack
|
||||||
|
drafts — but once that redraft is itself rejected (CANCELLED, so
|
||||||
|
excluded from ``list_open_x_posts``), a further redraft is allowed.
|
||||||
|
Also respects the shared open-post cap. The check+originate runs
|
||||||
|
under a per-identity Redis lock (``_acquire_redraft_lock`` — plain
|
||||||
|
SET NX, mirroring ``XPostService._acquire_lock``) so two deferred
|
||||||
|
closures racing for the SAME identity can't both pass the dedup
|
||||||
|
check and both originate; a held lock or an unreachable Redis skips
|
||||||
|
the redraft rather than risking a duplicate (the CEO can re-reject).
|
||||||
|
|
||||||
|
Best-effort: never raises. Any failure (unresolvable project,
|
||||||
|
local-model error) logs a warning and returns None — the caller's
|
||||||
|
reject must succeed regardless of this seam.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self._redraft_from_rejection(post_task, reason)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"x-engine: redraft from rejection failed",
|
||||||
|
task_id=str(post_task.id),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _redraft_from_rejection(
|
||||||
|
self, post_task: TaskTable, reason: str
|
||||||
|
) -> TaskTable | None:
|
||||||
|
if post_task.source not in X_SOURCES:
|
||||||
|
return None
|
||||||
|
identity = self._redraft_identity(post_task)
|
||||||
|
if identity is None:
|
||||||
|
# No stable discriminator to lock on (shouldn't happen for a real
|
||||||
|
# draft) — proceed unlocked, same as before this identity existed.
|
||||||
|
return await self._redraft_from_rejection_body(post_task, reason)
|
||||||
|
lock_key = f"{_REDRAFT_LOCK_PREFIX}{identity[0]}:{identity[1]}"
|
||||||
|
try:
|
||||||
|
token = await self._acquire_redraft_lock(lock_key)
|
||||||
|
except _RedraftLockUnavailable as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"x-engine: redraft lock unavailable (redis down); skipping redraft",
|
||||||
|
task_id=str(post_task.id),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if token is None:
|
||||||
|
self.log.info(
|
||||||
|
"x-engine: a redraft is already in flight for this item; "
|
||||||
|
"skipping (the concurrent holder is already originating one)",
|
||||||
|
task_id=str(post_task.id),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await self._redraft_from_rejection_body(post_task, reason)
|
||||||
|
finally:
|
||||||
|
await self._release_redraft_lock(lock_key, token)
|
||||||
|
|
||||||
|
async def _redraft_from_rejection_body(
|
||||||
|
self, post_task: TaskTable, reason: str
|
||||||
|
) -> TaskTable | None:
|
||||||
|
"""The check+originate critical section, run under the per-identity
|
||||||
|
redraft lock (or unlocked for an identity-less task — see caller)."""
|
||||||
|
open_posts = await get_task_service(self.session).list_open_x_posts()
|
||||||
|
if self._redraft_already_open(post_task, open_posts):
|
||||||
|
return None
|
||||||
|
if len(open_posts) >= settings.x_max_open_posts:
|
||||||
|
self.log.warning(
|
||||||
|
"x-engine: open-post cap reached; not redrafting rejected post",
|
||||||
|
task_id=str(post_task.id),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
project = await self._resolve_redraft_project(post_task)
|
||||||
|
if project is None or project.id is None:
|
||||||
|
self.log.warning(
|
||||||
|
"x-engine: project not resolvable; skipping redraft",
|
||||||
|
task_id=str(post_task.id),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return await self._materialize_redraft(post_task, reason, project)
|
||||||
|
|
||||||
|
async def _acquire_redraft_lock(self, lock_key: str) -> str | None:
|
||||||
|
"""Non-blocking SET NX — mirrors XPostService._acquire_lock (same
|
||||||
|
style, a different key namespace)."""
|
||||||
|
token = uuid4().hex
|
||||||
|
try:
|
||||||
|
conn = redis.from_url(settings.redis_url)
|
||||||
|
try:
|
||||||
|
acquired = await conn.set(
|
||||||
|
lock_key, token, nx=True, ex=_REDRAFT_LOCK_TTL_SECONDS
|
||||||
|
)
|
||||||
|
return token if acquired else None
|
||||||
|
finally:
|
||||||
|
await conn.aclose()
|
||||||
|
except Exception as exc:
|
||||||
|
raise _RedraftLockUnavailable(str(exc)) from exc
|
||||||
|
|
||||||
|
async def _release_redraft_lock(self, lock_key: str, token: str) -> None:
|
||||||
|
try:
|
||||||
|
conn = redis.from_url(settings.redis_url)
|
||||||
|
try:
|
||||||
|
await conn.eval(_REDRAFT_RELEASE_SCRIPT, 1, lock_key, token)
|
||||||
|
finally:
|
||||||
|
await conn.aclose()
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"x-engine: redraft lock release failed (redis)", error=str(exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _redraft_already_open(
|
||||||
|
self, post_task: TaskTable, open_posts: list[TaskTable]
|
||||||
|
) -> bool:
|
||||||
|
"""True when a redraft is already open for the SAME underlying item
|
||||||
|
(matched by ``_redraft_identity``) — the occasion-scoped dedup
|
||||||
|
``open_video_task`` does for video, adapted to X's three sources. An
|
||||||
|
identity-less task (shouldn't happen for a real draft) never dedups."""
|
||||||
|
identity = self._redraft_identity(post_task)
|
||||||
|
if identity is None:
|
||||||
|
return False
|
||||||
|
return any(self._redraft_identity(t) == identity for t in open_posts)
|
||||||
|
|
||||||
|
def _redraft_identity(self, task: TaskTable) -> tuple[str, str] | None:
|
||||||
|
"""(source, key) discriminating one draft's underlying item from
|
||||||
|
another of the same source: release version for x_post, mention id
|
||||||
|
for x_reply, feature slug for x_feature. None when the task carries
|
||||||
|
no such marker."""
|
||||||
|
if task.source == X_POST_SOURCE:
|
||||||
|
version = markers.get_x_release_version(task)
|
||||||
|
return (X_POST_SOURCE, version) if version else None
|
||||||
|
if task.source == X_REPLY_SOURCE:
|
||||||
|
mention_id = (markers.get_x_mention_ref(task) or {}).get("id")
|
||||||
|
return (X_REPLY_SOURCE, str(mention_id)) if mention_id else None
|
||||||
|
if task.source == X_FEATURE_SOURCE:
|
||||||
|
slug = (markers.get_x_feature_ref(task) or {}).get("slug")
|
||||||
|
return (X_FEATURE_SOURCE, str(slug)) if slug else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _resolve_redraft_project(
|
||||||
|
self, post_task: TaskTable
|
||||||
|
) -> ProjectTable | None:
|
||||||
|
"""The rejected draft's own project (every ``_originate_post`` call
|
||||||
|
sets one), or None when unresolvable."""
|
||||||
|
if post_task.project_id is None:
|
||||||
|
return None
|
||||||
|
return await get_project_service(self.session).get(
|
||||||
|
cast("UUID", post_task.project_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _materialize_redraft(
|
||||||
|
self, post_task: TaskTable, reason: str, project: ProjectTable
|
||||||
|
) -> TaskTable | None:
|
||||||
|
product_name = await get_company_goals_service(
|
||||||
|
self.session
|
||||||
|
).resolve_product_name(project)
|
||||||
|
rejected_body = (
|
||||||
|
markers.get_x_draft_body(post_task) or post_task.description or ""
|
||||||
|
)
|
||||||
|
body = await self._draft_revision_body(
|
||||||
|
post_task=post_task,
|
||||||
|
rejected_body=rejected_body,
|
||||||
|
reason=reason,
|
||||||
|
product_name=product_name,
|
||||||
|
)
|
||||||
|
if body is None:
|
||||||
|
self.log.info(
|
||||||
|
"x-engine: local model produced no revision; skipping redraft "
|
||||||
|
"(no degraded copy)",
|
||||||
|
task_id=str(post_task.id),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
task = await self._originate_post(
|
||||||
|
title=post_task.title or "X post revision",
|
||||||
|
body=body,
|
||||||
|
source=post_task.source,
|
||||||
|
project_id=cast("UUID", project.id),
|
||||||
|
)
|
||||||
|
self._carry_redraft_markers(task, post_task)
|
||||||
|
await self.session.flush()
|
||||||
|
self.log.info(
|
||||||
|
"x-engine: redraft opened after CEO rejection (held for CEO)",
|
||||||
|
rejected_task_id=str(post_task.id),
|
||||||
|
redraft_id=str(task.id),
|
||||||
|
)
|
||||||
|
return task
|
||||||
|
|
||||||
|
async def _draft_revision_body(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
post_task: TaskTable,
|
||||||
|
rejected_body: str,
|
||||||
|
reason: str,
|
||||||
|
product_name: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""The clamped revision body, or None when the local model produced
|
||||||
|
nothing usable — mirrors ``_draft_reply_body``'s no-degraded-copy
|
||||||
|
posture (see ``redraft_from_rejection``)."""
|
||||||
|
voice = await self._voice_guide(product_name)
|
||||||
|
context = self._redraft_context(post_task)
|
||||||
|
try:
|
||||||
|
draft = await _chat(
|
||||||
|
_revision_prompt(rejected_body, reason, voice, product_name, context)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.log.warning(
|
||||||
|
"x-engine: local-model redraft failed (no degraded copy)",
|
||||||
|
task_id=str(post_task.id),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
stripped = (draft or "").strip()
|
||||||
|
return _clamp_tweet(stripped) if stripped else None
|
||||||
|
|
||||||
|
def _redraft_context(self, post_task: TaskTable) -> str:
|
||||||
|
"""One line of source-specific factual grounding folded into the
|
||||||
|
revision prompt, so the redraft doesn't drift from what the post is
|
||||||
|
actually about."""
|
||||||
|
if post_task.source == X_POST_SOURCE:
|
||||||
|
version = markers.get_x_release_version(post_task)
|
||||||
|
return (
|
||||||
|
f"This is a release announcement for version {version}."
|
||||||
|
if version
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
if post_task.source == X_REPLY_SOURCE:
|
||||||
|
text = (markers.get_x_mention_ref(post_task) or {}).get("text")
|
||||||
|
return f"This is a reply to this X mention:\n{text}" if text else ""
|
||||||
|
if post_task.source == X_FEATURE_SOURCE:
|
||||||
|
title = (markers.get_x_feature_ref(post_task) or {}).get("title")
|
||||||
|
return f"This is a feature-spotlight post about: {title}." if title else ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _carry_redraft_markers(self, new_task: TaskTable, post_task: TaskTable) -> None:
|
||||||
|
"""Copy the rejected draft's source-specific reference marker onto the
|
||||||
|
redraft so it renders identically in the panel queue/history and is
|
||||||
|
itself reject/approve-able through the normal flow. The feature
|
||||||
|
source's seen-slug bookkeeping is NOT repeated here — the original
|
||||||
|
draft already marked it seen at authoring time."""
|
||||||
|
if post_task.source == X_POST_SOURCE:
|
||||||
|
version = markers.get_x_release_version(post_task)
|
||||||
|
if version:
|
||||||
|
markers.set_x_release_version(new_task, version)
|
||||||
|
elif post_task.source == X_REPLY_SOURCE:
|
||||||
|
ref = markers.get_x_mention_ref(post_task)
|
||||||
|
if ref:
|
||||||
|
markers.set_x_mention_ref(new_task, ref)
|
||||||
|
elif post_task.source == X_FEATURE_SOURCE:
|
||||||
|
ref = markers.get_x_feature_ref(post_task)
|
||||||
|
if ref:
|
||||||
|
markers.set_x_feature_ref(new_task, ref)
|
||||||
|
|
||||||
|
|
||||||
def get_x_engine(session: AsyncSession, client: XClient | None = None) -> XEngine:
|
def get_x_engine(session: AsyncSession, client: XClient | None = None) -> XEngine:
|
||||||
"""Build an XEngine for ``session`` (optional injected client for tests)."""
|
"""Build an XEngine for ``session`` (optional injected client for tests)."""
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from roboco.config import settings
|
|||||||
from roboco.foundation.policy.content import markers
|
from roboco.foundation.policy.content import markers
|
||||||
from roboco.models.base import TaskStatus
|
from roboco.models.base import TaskStatus
|
||||||
from roboco.services.base import BaseService
|
from roboco.services.base import BaseService
|
||||||
|
from roboco.services.notification_delivery import defer_after_commit
|
||||||
from roboco.services.task import X_FEATURE_SOURCE, X_SOURCES, get_task_service
|
from roboco.services.task import X_FEATURE_SOURCE, X_SOURCES, get_task_service
|
||||||
from roboco.services.x_client import MAX_TWEET_CHARS, build_x_client
|
from roboco.services.x_client import MAX_TWEET_CHARS, build_x_client
|
||||||
from roboco.services.x_credentials import get_x_credentials_service
|
from roboco.services.x_credentials import get_x_credentials_service
|
||||||
@@ -253,7 +254,9 @@ class XPostService(BaseService):
|
|||||||
logger.warning("spotlight video draft failed (best-effort): %s", exc)
|
logger.warning("spotlight video draft failed (best-effort): %s", exc)
|
||||||
|
|
||||||
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
|
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
|
||||||
"""Record the CEO's reason and cancel the draft (never posted).
|
"""Record the CEO's reason, cancel the draft (never posted), and — for
|
||||||
|
a non-blank reason — schedule a redraft of the same source once this
|
||||||
|
transaction commits.
|
||||||
|
|
||||||
Acquires the same post-mutex ``approve()`` holds (same key, same
|
Acquires the same post-mutex ``approve()`` holds (same key, same
|
||||||
non-blocking acquire style) so a reject can't interleave with a
|
non-blocking acquire style) so a reject can't interleave with a
|
||||||
@@ -263,40 +266,125 @@ class XPostService(BaseService):
|
|||||||
approve's own COMPLETED write right after it landed. Fails CLOSED
|
approve's own COMPLETED write right after it landed. Fails CLOSED
|
||||||
like approve, both when the lock is held (approve mid-post) and when
|
like approve, both when the lock is held (approve mid-post) and when
|
||||||
Redis is unreachable — the CEO retries the reject once it clears.
|
Redis is unreachable — the CEO retries the reject once it clears.
|
||||||
|
|
||||||
|
Idempotent on an already-CANCELLED target — a stale/replayed reject
|
||||||
|
(e.g. a double-tapped Telegram button) returns the task UNCHANGED
|
||||||
|
rather than re-flushing the reason and, worse, scheduling ANOTHER
|
||||||
|
redraft; mirrors ``_approve_locked``'s ``already_rejected``
|
||||||
|
short-circuit on the approve side.
|
||||||
|
|
||||||
|
The redraft itself is scheduled via ``_schedule_redraft`` (see its
|
||||||
|
docstring for why it's deferred rather than inline) — a redraft
|
||||||
|
failure never fails or delays this call, and a blank/whitespace
|
||||||
|
reason schedules nothing (plain cancel semantics unchanged).
|
||||||
"""
|
"""
|
||||||
task = await get_task_service(self.session).get(task_id)
|
task = await get_task_service(self.session).get(task_id)
|
||||||
if task is None or task.source not in X_SOURCES:
|
if task is None or task.source not in X_SOURCES:
|
||||||
return None
|
return None
|
||||||
if task.status == TaskStatus.COMPLETED:
|
terminal = self._reject_terminal_check(task, task_id)
|
||||||
raise TaskAlreadyCompletedError(
|
if terminal is not None:
|
||||||
f"X draft {task_id} already posted (COMPLETED); cannot be rejected"
|
return terminal
|
||||||
)
|
|
||||||
|
|
||||||
lock_key = f"{_LOCK_PREFIX}{task_id}"
|
lock_key = f"{_LOCK_PREFIX}{task_id}"
|
||||||
try:
|
token = await self._try_acquire_reject_lock(lock_key)
|
||||||
token = await self._acquire_lock(lock_key)
|
|
||||||
except _LockUnavailable as exc:
|
|
||||||
logger.error("x-post reject lock unavailable (redis down): %s", exc)
|
|
||||||
return None
|
|
||||||
if token is None:
|
if token is None:
|
||||||
return None # a concurrent approve is mid-post; refuse the reject
|
return None # redis down, or a concurrent approve is mid-post
|
||||||
try:
|
try:
|
||||||
# Re-read under the lock: a concurrent approve may have posted +
|
# Re-read under the lock: a concurrent approve may have posted +
|
||||||
# committed COMPLETED between the pre-lock check and here.
|
# committed COMPLETED between the pre-lock check and here, or a
|
||||||
|
# concurrent reject may have already landed CANCELLED.
|
||||||
self.session.expire(task)
|
self.session.expire(task)
|
||||||
locked = await get_task_service(self.session).get(task_id)
|
locked = await get_task_service(self.session).get(task_id)
|
||||||
if locked is None:
|
if locked is None:
|
||||||
return None
|
return None
|
||||||
if locked.status == TaskStatus.COMPLETED:
|
terminal = self._reject_terminal_check(locked, task_id)
|
||||||
raise TaskAlreadyCompletedError(
|
if terminal is not None:
|
||||||
f"X draft {task_id} already posted (COMPLETED); cannot be rejected"
|
return terminal
|
||||||
)
|
|
||||||
markers.set_x_reject_reason(locked, reason)
|
markers.set_x_reject_reason(locked, reason)
|
||||||
locked.status = TaskStatus.CANCELLED
|
locked.status = TaskStatus.CANCELLED
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return locked
|
|
||||||
finally:
|
finally:
|
||||||
await self._release_lock(lock_key, token)
|
await self._release_lock(lock_key, token)
|
||||||
|
# Outside the lock, after the cancel is flushed: a non-blank reason
|
||||||
|
# schedules the redraft. Never inline here (see _schedule_redraft).
|
||||||
|
if reason.strip():
|
||||||
|
self._schedule_redraft(task_id, reason)
|
||||||
|
return locked
|
||||||
|
|
||||||
|
def _reject_terminal_check(
|
||||||
|
self, task: TaskTable, task_id: UUID
|
||||||
|
) -> TaskTable | None:
|
||||||
|
"""Shared by the pre-lock and locked-under-mutex checks in
|
||||||
|
``reject``: raises on COMPLETED (already posted, can't be
|
||||||
|
rejected), returns ``task`` unchanged on CANCELLED (an idempotent
|
||||||
|
replay — e.g. a double-tapped Telegram button — is a no-op, never
|
||||||
|
re-flushing the reason or scheduling ANOTHER redraft), else None to
|
||||||
|
mean "not terminal, proceed with the cancel"."""
|
||||||
|
if task.status == TaskStatus.COMPLETED:
|
||||||
|
raise TaskAlreadyCompletedError(
|
||||||
|
f"X draft {task_id} already posted (COMPLETED); cannot be rejected"
|
||||||
|
)
|
||||||
|
if task.status == TaskStatus.CANCELLED:
|
||||||
|
return task
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _try_acquire_reject_lock(self, lock_key: str) -> str | None:
|
||||||
|
"""``_acquire_lock`` wrapped so a Redis-down failure and an
|
||||||
|
already-held lock look identical to the caller (both mean "can't
|
||||||
|
safely reject right now") — collapses two ``reject()`` return
|
||||||
|
sites (and the try/except) into one, under the complexity gate."""
|
||||||
|
try:
|
||||||
|
return await self._acquire_lock(lock_key)
|
||||||
|
except _LockUnavailable as exc:
|
||||||
|
logger.error("x-post reject lock unavailable (redis down): %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _schedule_redraft(self, task_id: UUID, reason: str) -> None:
|
||||||
|
"""Enqueue XEngine.redraft_from_rejection to run only after THIS
|
||||||
|
session's transaction actually commits (``defer_after_commit`` —
|
||||||
|
registers on ``self.session``'s own ``after_commit``/``after_rollback``
|
||||||
|
events, so it fires regardless of whether the caller commits inside
|
||||||
|
this service call or, as the real route does, right after it
|
||||||
|
returns; a rollback drops it, never redrafting a reject that never
|
||||||
|
became durable).
|
||||||
|
|
||||||
|
Deferred rather than inline for two reasons: ``reject`` is an HTTP
|
||||||
|
route handler and the local-model call inside the redraft can take
|
||||||
|
seconds — the response must not block on it; and by the time the
|
||||||
|
deferred callable actually runs, the request's own ``AsyncSession``
|
||||||
|
may already be closing (FastAPI tears it down once the route
|
||||||
|
returns). So the callable opens a FRESH session from the process
|
||||||
|
session factory instead of touching ``self.session`` — the same
|
||||||
|
fresh-session-in-a-background-task shape ``TaskService.
|
||||||
|
_inject_proactive_context`` uses for its own post-commit background
|
||||||
|
write. Best-effort throughout: any failure (session error, project
|
||||||
|
unresolvable, local-model down) only logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _redraft() -> None:
|
||||||
|
from roboco.db.base import get_session_factory
|
||||||
|
from roboco.db.tables import TaskTable
|
||||||
|
from roboco.services.x_engine import get_x_engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
async with session_factory() as session:
|
||||||
|
fresh = await session.get(TaskTable, task_id)
|
||||||
|
if fresh is None:
|
||||||
|
return
|
||||||
|
redrafted = await get_x_engine(session).redraft_from_rejection(
|
||||||
|
fresh, reason
|
||||||
|
)
|
||||||
|
if redrafted is not None:
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc: # best-effort: never break the drain
|
||||||
|
logger.warning(
|
||||||
|
"x-post redraft-after-commit failed for task %s: %s",
|
||||||
|
task_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
defer_after_commit(self.session, _redraft)
|
||||||
|
|
||||||
# ---- Redis single-flight lock (plain SET NX — no heartbeat needed) -----
|
# ---- Redis single-flight lock (plain SET NX — no heartbeat needed) -----
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ posts — asserted against a real Postgres DB.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, patch
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -47,6 +48,8 @@ from roboco.services.x_client import MAX_TWEET_CHARS, XClient, XMention, XPostRe
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||||
@@ -1000,6 +1003,266 @@ async def test_materialize_feature_spotlight_enforces_280_chars(
|
|||||||
assert len(body) <= MAX_TWEET_CHARS
|
assert len(body) <= MAX_TWEET_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# redraft_from_rejection — CEO reject feedback loop (mirrors VideoEngine.
|
||||||
|
# reauthor_from_rejection). ``post_task.status = TS.CANCELLED`` below mirrors
|
||||||
|
# XPostService.reject: by the time redraft_from_rejection runs, the rejected
|
||||||
|
# draft is already cancelled and excluded from list_open_x_posts.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _redraft_lock_free() -> Iterator[None]:
|
||||||
|
"""Patch XEngine's redraft-dedup lock helpers so a test exercises the
|
||||||
|
real check+originate path without touching the (test-blocked, see
|
||||||
|
conftest's `_no_live_redis`) Redis — mirrors test_x_post_service.py's
|
||||||
|
`_lock_free()` for the post-mutex. Every redraft test below that expects
|
||||||
|
the redraft to actually run needs this; the dedicated lock-held test
|
||||||
|
patches the SAME methods on the instance instead, to assert the opposite."""
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
x_engine_module.XEngine,
|
||||||
|
"_acquire_redraft_lock",
|
||||||
|
AsyncMock(return_value="tok"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
x_engine_module.XEngine,
|
||||||
|
"_release_redraft_lock",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_release_post_carries_version_and_new_body(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
await _seed(db_session)
|
||||||
|
_enable(monkeypatch)
|
||||||
|
_mock_local_model(monkeypatch, "shipped!")
|
||||||
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
|
post_task = await engine.draft_release_post(
|
||||||
|
version=_VERSION, highlights=["feat: x"]
|
||||||
|
)
|
||||||
|
assert post_task is not None
|
||||||
|
original_body = markers.get_x_draft_body(post_task)
|
||||||
|
post_task.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
_mock_local_model(monkeypatch, "A sharper revised release announcement.")
|
||||||
|
with _redraft_lock_free():
|
||||||
|
redraft = await engine.redraft_from_rejection(
|
||||||
|
post_task, "Too vague, name the feature"
|
||||||
|
)
|
||||||
|
assert redraft is not None
|
||||||
|
assert redraft.id != post_task.id
|
||||||
|
assert redraft.source == X_POST_SOURCE
|
||||||
|
assert redraft.status == TS.PENDING
|
||||||
|
assert redraft.confirmed_by_human is False
|
||||||
|
assert markers.get_x_release_version(redraft) == _VERSION
|
||||||
|
body = markers.get_x_draft_body(redraft)
|
||||||
|
assert body == "A sharper revised release announcement."
|
||||||
|
assert body != original_body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_reply_carries_mention_ref(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
await _seed(db_session)
|
||||||
|
_enable(monkeypatch)
|
||||||
|
_mock_local_model(monkeypatch, "reply")
|
||||||
|
engine = x_engine_module.XEngine(
|
||||||
|
db_session, client=_FakeClient(mentions=[_mention("m1", text="great work")])
|
||||||
|
)
|
||||||
|
result = await engine.run_cycle()
|
||||||
|
assert len(result) == ONE
|
||||||
|
post_task = result[0]
|
||||||
|
post_task.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
_mock_local_model(monkeypatch, "A better reply.")
|
||||||
|
with _redraft_lock_free():
|
||||||
|
redraft = await engine.redraft_from_rejection(post_task, "Too generic")
|
||||||
|
assert redraft is not None
|
||||||
|
assert redraft.source == X_REPLY_SOURCE
|
||||||
|
ref = markers.get_x_mention_ref(redraft)
|
||||||
|
assert ref is not None
|
||||||
|
assert ref["id"] == "m1"
|
||||||
|
assert markers.get_x_draft_body(redraft) == "A better reply."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_feature_carries_feature_ref(
|
||||||
|
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
|
||||||
|
post_task = 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?",
|
||||||
|
)
|
||||||
|
post_task.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
_mock_local_model(monkeypatch, "A sharper spotlight tweet.")
|
||||||
|
with _redraft_lock_free():
|
||||||
|
redraft = await engine.redraft_from_rejection(post_task, "Too dry")
|
||||||
|
assert redraft is not None
|
||||||
|
assert redraft.source == X_FEATURE_SOURCE
|
||||||
|
ref = markers.get_x_feature_ref(redraft)
|
||||||
|
assert ref is not None
|
||||||
|
assert ref["slug"] == "org-memory"
|
||||||
|
# No duplicate seen-slug insert — the original materialize already marked it.
|
||||||
|
assert await engine.is_feature_seen("org-memory") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_local_model_failure_originates_nothing(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""A local-model failure must never ship a degraded copy — no redraft."""
|
||||||
|
await _seed(db_session)
|
||||||
|
_enable(monkeypatch)
|
||||||
|
_mock_local_model(monkeypatch, "shipped!")
|
||||||
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
|
post_task = await engine.draft_release_post(version=_VERSION, highlights=[])
|
||||||
|
assert post_task is not None
|
||||||
|
post_task.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
x_engine_module, "_chat", AsyncMock(side_effect=RuntimeError("ollama down"))
|
||||||
|
)
|
||||||
|
with _redraft_lock_free():
|
||||||
|
redraft = await engine.redraft_from_rejection(post_task, "Needs work")
|
||||||
|
assert redraft is None
|
||||||
|
open_posts = await get_task_service(db_session).list_open_x_posts()
|
||||||
|
assert open_posts == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_empty_local_model_reply_originates_nothing(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
await _seed(db_session)
|
||||||
|
_enable(monkeypatch)
|
||||||
|
_mock_local_model(monkeypatch, "shipped!")
|
||||||
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
|
post_task = await engine.draft_release_post(version=_VERSION, highlights=[])
|
||||||
|
assert post_task is not None
|
||||||
|
post_task.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
_mock_local_model(monkeypatch, None)
|
||||||
|
with _redraft_lock_free():
|
||||||
|
redraft = await engine.redraft_from_rejection(post_task, "Needs work")
|
||||||
|
assert redraft is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_respects_open_cap(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
await _seed(db_session)
|
||||||
|
_enable(monkeypatch, x_max_open_posts=1)
|
||||||
|
_mock_local_model(monkeypatch, "shipped!")
|
||||||
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
|
post_task = await engine.draft_release_post(version="1.0.0", highlights=[])
|
||||||
|
assert post_task is not None
|
||||||
|
post_task.status = TS.CANCELLED # rejected; no longer counts toward the cap
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Fill the cap with an unrelated open draft (a mention reply).
|
||||||
|
filler_client = _FakeClient(mentions=[_mention("cap-fill")])
|
||||||
|
filler_engine = x_engine_module.XEngine(db_session, client=filler_client)
|
||||||
|
filler = await filler_engine.run_cycle()
|
||||||
|
assert len(filler) == ONE
|
||||||
|
|
||||||
|
with _redraft_lock_free():
|
||||||
|
redraft = await engine.redraft_from_rejection(post_task, "Needs work")
|
||||||
|
assert redraft is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_dedupes_while_open_then_allows_after_rejection(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""A redraft is skipped while one is already open for the same item —
|
||||||
|
mirroring VideoEngine's occasion-scoped dedup — so a repeated reject
|
||||||
|
can't stack unbounded drafts. But once THAT redraft is itself rejected
|
||||||
|
(CANCELLED, so excluded from list_open_x_posts), a further redraft
|
||||||
|
proceeds: a genuine second revision round still works."""
|
||||||
|
await _seed(db_session)
|
||||||
|
_enable(monkeypatch)
|
||||||
|
_mock_local_model(monkeypatch, "shipped!")
|
||||||
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
|
post_task = await engine.draft_release_post(version=_VERSION, highlights=[])
|
||||||
|
assert post_task is not None
|
||||||
|
post_task.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with _redraft_lock_free():
|
||||||
|
_mock_local_model(monkeypatch, "First revision.")
|
||||||
|
first_redraft = await engine.redraft_from_rejection(
|
||||||
|
post_task, "Round 1 feedback"
|
||||||
|
)
|
||||||
|
assert first_redraft is not None
|
||||||
|
|
||||||
|
# A second reject of the already-cancelled original must not stack
|
||||||
|
# another draft while the first redraft is still open.
|
||||||
|
again = await engine.redraft_from_rejection(post_task, "Round 1 feedback again")
|
||||||
|
assert again is None
|
||||||
|
|
||||||
|
# Once the first redraft is itself rejected, a further redraft proceeds.
|
||||||
|
first_redraft.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
_mock_local_model(monkeypatch, "Second revision.")
|
||||||
|
second_redraft = await engine.redraft_from_rejection(
|
||||||
|
first_redraft, "Round 2 feedback"
|
||||||
|
)
|
||||||
|
assert second_redraft is not None
|
||||||
|
assert second_redraft.id != first_redraft.id
|
||||||
|
assert markers.get_x_draft_body(second_redraft) == "Second revision."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redraft_from_rejection_skipped_when_lock_held(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""A concurrent redraft already holding the per-identity lock makes this
|
||||||
|
call skip rather than race the dedup check — closes the TOCTOU where two
|
||||||
|
deferred closures for the SAME identity could otherwise both pass
|
||||||
|
``_redraft_already_open`` and both originate. A skipped redraft is safe:
|
||||||
|
the concurrent holder is already originating one, and the CEO can always
|
||||||
|
re-reject."""
|
||||||
|
await _seed(db_session)
|
||||||
|
_enable(monkeypatch)
|
||||||
|
_mock_local_model(monkeypatch, "shipped!")
|
||||||
|
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||||
|
post_task = await engine.draft_release_post(version=_VERSION, highlights=[])
|
||||||
|
assert post_task is not None
|
||||||
|
post_task.status = TS.CANCELLED
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Simulate a concurrent redraft already holding the lock for this identity.
|
||||||
|
release = AsyncMock()
|
||||||
|
monkeypatch.setattr(engine, "_acquire_redraft_lock", AsyncMock(return_value=None))
|
||||||
|
monkeypatch.setattr(engine, "_release_redraft_lock", release)
|
||||||
|
|
||||||
|
redraft = await engine.redraft_from_rejection(post_task, "Needs work")
|
||||||
|
assert redraft is None
|
||||||
|
release.assert_not_awaited() # never acquired, so never released
|
||||||
|
open_posts = await get_task_service(db_session).list_open_x_posts()
|
||||||
|
assert open_posts == []
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# CHANGELOG.md parsing — pure functions, no DB/network needed
|
# CHANGELOG.md parsing — pure functions, no DB/network needed
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ fixture) so approve exercises the real post + status-transition path.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from uuid import uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from roboco.config import settings as cfg
|
from roboco.config import settings as cfg
|
||||||
@@ -27,6 +28,8 @@ from roboco.models.base import (
|
|||||||
from roboco.models.base import TaskNature as TN
|
from roboco.models.base import TaskNature as TN
|
||||||
from roboco.models.base import TaskStatus as TS
|
from roboco.models.base import TaskStatus as TS
|
||||||
from roboco.models.base import TaskType as TT
|
from roboco.models.base import TaskType as TT
|
||||||
|
from roboco.services import x_engine as x_engine_module
|
||||||
|
from roboco.services.company_goals import get_company_goals_service
|
||||||
from roboco.services.task import (
|
from roboco.services.task import (
|
||||||
X_FEATURE_SOURCE,
|
X_FEATURE_SOURCE,
|
||||||
X_POST_SOURCE,
|
X_POST_SOURCE,
|
||||||
@@ -41,6 +44,7 @@ from roboco.services.x_post_service import (
|
|||||||
XPostService,
|
XPostService,
|
||||||
get_x_post_service,
|
get_x_post_service,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy import delete
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncEngine,
|
AsyncEngine,
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
@@ -50,7 +54,6 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||||
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
|
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
|
||||||
@@ -961,3 +964,295 @@ async def test_approve_feature_spotlight_video_failure_does_not_break_post(
|
|||||||
assert result.status == "posted"
|
assert result.status == "posted"
|
||||||
await db_session.refresh(task)
|
await db_session.refresh(task)
|
||||||
assert task.status == TS.COMPLETED
|
assert task.status == TS.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Reject -> redraft (CEO feedback loop): a non-blank reject reason schedules
|
||||||
|
# XEngine.redraft_from_rejection to run only after this session's transaction
|
||||||
|
# actually commits (`defer_after_commit`), via a FRESH session opened from
|
||||||
|
# `get_session_factory()` — patched here to the same test database
|
||||||
|
# `db_session` uses, since the production singleton points elsewhere in
|
||||||
|
# tests. Mirrors `test_notification_delivery_phantom.py`'s drain helpers
|
||||||
|
# (duplicated locally rather than imported — this project's convention for
|
||||||
|
# a small pure test helper, not a service internal).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_tasks(session: AsyncSession) -> list[asyncio.Task[object]]:
|
||||||
|
return list(session.info.get("_roboco_drain_tasks", []))
|
||||||
|
|
||||||
|
|
||||||
|
async def _await_drain(session: AsyncSession) -> None:
|
||||||
|
tasks = _drain_tasks(session)
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _redraft_engine_factory(
|
||||||
|
url: str,
|
||||||
|
) -> tuple[async_sessionmaker[AsyncSession], AsyncEngine]:
|
||||||
|
"""A session factory on a brand-new engine bound to the SAME test
|
||||||
|
database `db_session` uses — what `_schedule_redraft`'s deferred closure
|
||||||
|
opens via `get_session_factory()` at drain time, patched here instead of
|
||||||
|
the (unreachable-in-tests) production singleton."""
|
||||||
|
engine = create_async_engine(url, future=True)
|
||||||
|
factory = async_sessionmaker(
|
||||||
|
bind=engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
return factory, engine
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_tasks(session: AsyncSession, *task_ids: UUID) -> None:
|
||||||
|
"""Delete these task rows and commit.
|
||||||
|
|
||||||
|
The tests below exercise a REAL `session.commit()` (required to fire the
|
||||||
|
after-commit redraft), and `_test_database_url` is a SESSION-scoped
|
||||||
|
database shared by every test in the whole run — an uncommitted row is
|
||||||
|
cleaned up by `db_session`'s own rollback-at-teardown, but a committed
|
||||||
|
one is durable and would leak an open draft into every later test (in
|
||||||
|
this file and any other) that counts/lists open X drafts. Explicit
|
||||||
|
cleanup restores the shared DB to a clean slate.
|
||||||
|
"""
|
||||||
|
await session.execute(delete(TaskTable).where(TaskTable.id.in_(task_ids)))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _redraft_lock_free() -> Iterator[None]:
|
||||||
|
"""Patch XEngine's redraft-dedup lock helpers (class-level, since the
|
||||||
|
deferred `_redraft()` closure constructs a fresh `XEngine` each time) so
|
||||||
|
the redraft's check+originate exercises its real path without touching
|
||||||
|
the (test-blocked) Redis — mirrors `_lock_free()` above for the post
|
||||||
|
mutex."""
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
x_engine_module.XEngine,
|
||||||
|
"_acquire_redraft_lock",
|
||||||
|
AsyncMock(return_value="tok"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
x_engine_module.XEngine,
|
||||||
|
"_release_redraft_lock",
|
||||||
|
AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_with_reason_schedules_deferred_redraft(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""A non-blank reason enqueues the redraft on the after-commit outbox —
|
||||||
|
nothing runs before the transaction actually commits."""
|
||||||
|
task = await _seed_draft(db_session)
|
||||||
|
with _lock_free():
|
||||||
|
await _svc(db_session).reject(_id(task), "Too vague")
|
||||||
|
assert db_session.info.get("_roboco_pending_bus_publishes")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_blank_reason_schedules_no_redraft(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Preserves current semantics: a blank/whitespace reason is a plain
|
||||||
|
cancel, nothing scheduled."""
|
||||||
|
task = await _seed_draft(db_session)
|
||||||
|
with _lock_free():
|
||||||
|
await _svc(db_session).reject(_id(task), " ")
|
||||||
|
assert not db_session.info.get("_roboco_pending_bus_publishes")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_redraft_materializes_held_draft_of_same_source_with_new_body(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
_test_database_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""End to end: reject with a reason -> commit -> drain -> a fresh HELD
|
||||||
|
draft of the SAME source, carrying the local model's revised body.
|
||||||
|
|
||||||
|
Pins the fresh-session contract two ways: `db_session` is CLOSED before
|
||||||
|
the drain runs (SQLAlchemy silently reopens a connection on reuse, so
|
||||||
|
this alone can't force a raise — it's included anyway per spec, and
|
||||||
|
still proves the drain doesn't NEED the request session kept open); the
|
||||||
|
real teeth is `get_x_engine` wrapped to capture the actual session
|
||||||
|
XEngine is constructed with and asserting it is NOT `db_session` — that
|
||||||
|
catches a "captured self.session instead of opening a fresh one"
|
||||||
|
regression regardless of `.close()`'s (non-)effect.
|
||||||
|
"""
|
||||||
|
# A non-empty brand_voice skips XEngine's one-time nudge notification —
|
||||||
|
# this test's own commit would otherwise durably flip that GLOBAL
|
||||||
|
# "already nudged" system_settings flag for the rest of the suite.
|
||||||
|
await get_company_goals_service(db_session).upsert(
|
||||||
|
{"brand_voice": "Confident, concise, no fluff."}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
x_engine_module, "_chat", AsyncMock(return_value="Revised body.")
|
||||||
|
)
|
||||||
|
task = await _seed_draft(db_session, source=X_POST_SOURCE, body="Original body")
|
||||||
|
task_id = _id(task)
|
||||||
|
|
||||||
|
factory, engine = await _redraft_engine_factory(_test_database_url)
|
||||||
|
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
|
||||||
|
|
||||||
|
captured_sessions: list[AsyncSession] = []
|
||||||
|
real_get_x_engine = x_engine_module.get_x_engine
|
||||||
|
|
||||||
|
def _capturing_get_x_engine(
|
||||||
|
session: AsyncSession, client: XClient | None = None
|
||||||
|
) -> x_engine_module.XEngine:
|
||||||
|
captured_sessions.append(session)
|
||||||
|
return real_get_x_engine(session, client=client)
|
||||||
|
|
||||||
|
monkeypatch.setattr(x_engine_module, "get_x_engine", _capturing_get_x_engine)
|
||||||
|
|
||||||
|
with _redraft_lock_free():
|
||||||
|
try:
|
||||||
|
with _lock_free():
|
||||||
|
await _svc(db_session).reject(task_id, "Needs a concrete detail")
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.close()
|
||||||
|
await _await_drain(db_session)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
assert len(captured_sessions) == 1
|
||||||
|
assert captured_sessions[0] is not db_session
|
||||||
|
|
||||||
|
open_posts = await _svc(db_session).list_open_posts()
|
||||||
|
redrafts = [t for t in open_posts if t.id != task_id]
|
||||||
|
assert len(redrafts) == 1
|
||||||
|
redraft = redrafts[0]
|
||||||
|
assert redraft.source == X_POST_SOURCE
|
||||||
|
assert redraft.status == TS.PENDING
|
||||||
|
assert redraft.confirmed_by_human is False
|
||||||
|
assert markers.get_x_draft_body(redraft) == "Revised body."
|
||||||
|
|
||||||
|
await _delete_tasks(db_session, task_id, UUID(str(redraft.id)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_redraft_local_model_failure_originates_nothing(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
_test_database_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""A local-model failure at redraft time never ships a degraded copy —
|
||||||
|
the reject stays a plain cancel with no fresh draft."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
x_engine_module, "_chat", AsyncMock(side_effect=RuntimeError("ollama down"))
|
||||||
|
)
|
||||||
|
task = await _seed_draft(db_session, source=X_POST_SOURCE)
|
||||||
|
task_id = _id(task)
|
||||||
|
|
||||||
|
factory, engine = await _redraft_engine_factory(_test_database_url)
|
||||||
|
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
|
||||||
|
with _redraft_lock_free():
|
||||||
|
try:
|
||||||
|
with _lock_free():
|
||||||
|
updated = await _svc(db_session).reject(task_id, "Needs work")
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == TS.CANCELLED
|
||||||
|
await db_session.commit()
|
||||||
|
await _await_drain(db_session)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
open_posts = await _svc(db_session).list_open_posts()
|
||||||
|
assert open_posts == []
|
||||||
|
|
||||||
|
await _delete_tasks(db_session, task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_redraft_respects_open_post_cap(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
_test_database_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""The shared open-post cap blocks the redraft exactly like it blocks any
|
||||||
|
other origination — an unrelated open draft filling the cap means the
|
||||||
|
rejected item gets no revision this cycle."""
|
||||||
|
monkeypatch.setattr(cfg, "x_max_open_posts", 1)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
x_engine_module, "_chat", AsyncMock(return_value="Revised body.")
|
||||||
|
)
|
||||||
|
task = await _seed_draft(db_session, source=X_POST_SOURCE)
|
||||||
|
task_id = _id(task)
|
||||||
|
filler = await _seed_draft(db_session, source=X_REPLY_SOURCE, body="Filler reply")
|
||||||
|
|
||||||
|
factory, engine = await _redraft_engine_factory(_test_database_url)
|
||||||
|
monkeypatch.setattr("roboco.db.base.get_session_factory", lambda: factory)
|
||||||
|
with _redraft_lock_free():
|
||||||
|
try:
|
||||||
|
with _lock_free():
|
||||||
|
await _svc(db_session).reject(task_id, "Needs a concrete detail")
|
||||||
|
await db_session.commit()
|
||||||
|
await _await_drain(db_session)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
open_posts = await _svc(db_session).list_open_posts()
|
||||||
|
ids = {t.id for t in open_posts}
|
||||||
|
assert ids == {filler.id} # no redraft — the cap was already at 1
|
||||||
|
|
||||||
|
await _delete_tasks(db_session, task_id, UUID(str(filler.id)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_survives_deferred_session_factory_failure(
|
||||||
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""A redraft failure at drain time (here: the fresh-session open itself
|
||||||
|
blowing up) must never break the reject — it already committed CANCELLED
|
||||||
|
before this best-effort seam ever runs."""
|
||||||
|
task = await _seed_draft(db_session, source=X_POST_SOURCE)
|
||||||
|
task_id = _id(task)
|
||||||
|
|
||||||
|
def _boom() -> async_sessionmaker[AsyncSession]:
|
||||||
|
raise RuntimeError("db down")
|
||||||
|
|
||||||
|
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
|
||||||
|
with _lock_free():
|
||||||
|
updated = await _svc(db_session).reject(task_id, "Needs work")
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.status == TS.CANCELLED
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
await _await_drain(db_session) # must not raise
|
||||||
|
|
||||||
|
await db_session.refresh(updated)
|
||||||
|
assert updated.status == TS.CANCELLED
|
||||||
|
assert markers.get_x_reject_reason(updated) == "Needs work"
|
||||||
|
|
||||||
|
await _delete_tasks(db_session, task_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reject_replayed_on_already_cancelled_is_noop(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""A second reject() on an already-CANCELLED task (a stale/replayed
|
||||||
|
request — e.g. a double-tapped Telegram button) is idempotent: it
|
||||||
|
returns the task UNCHANGED, never re-flushes the reason, and schedules
|
||||||
|
NO second redraft — mirroring approve()'s already_rejected
|
||||||
|
short-circuit. Pre-fix this re-flushed CANCELLED and scheduled another
|
||||||
|
redraft on every replay."""
|
||||||
|
task = await _seed_draft(db_session, source=X_POST_SOURCE)
|
||||||
|
task_id = _id(task)
|
||||||
|
with _lock_free():
|
||||||
|
first = await _svc(db_session).reject(task_id, "Needs work")
|
||||||
|
assert first is not None
|
||||||
|
assert first.status == TS.CANCELLED
|
||||||
|
# The first reject scheduled its own redraft — clear the pending queue
|
||||||
|
# so the assertion below is unambiguous about what the SECOND call does.
|
||||||
|
db_session.info.pop("_roboco_pending_bus_publishes", None)
|
||||||
|
|
||||||
|
with _lock_free():
|
||||||
|
second = await _svc(db_session).reject(task_id, "A completely different reason")
|
||||||
|
assert second is not None
|
||||||
|
assert second.status == TS.CANCELLED
|
||||||
|
# Unchanged: the replay's reason must NOT overwrite the original.
|
||||||
|
assert markers.get_x_reject_reason(second) == "Needs work"
|
||||||
|
assert not db_session.info.get("_roboco_pending_bus_publishes")
|
||||||
|
|||||||
Reference in New Issue
Block a user