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:
@@ -8,9 +8,10 @@ posts — asserted against a real Postgres DB.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
@@ -47,6 +48,8 @@ from roboco.services.x_client import MAX_TWEET_CHARS, XClient, XMention, XPostRe
|
||||
from sqlalchemy import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -7,11 +7,12 @@ fixture) so approve exercises the real post + status-transition path.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
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 TaskStatus as TS
|
||||
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 (
|
||||
X_FEATURE_SOURCE,
|
||||
X_POST_SOURCE,
|
||||
@@ -41,6 +44,7 @@ from roboco.services.x_post_service import (
|
||||
XPostService,
|
||||
get_x_post_service,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
@@ -50,7 +54,6 @@ from sqlalchemy.ext.asyncio import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from uuid import UUID
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].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"
|
||||
await db_session.refresh(task)
|
||||
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