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:
Renzo F
2026-07-22 20:30:01 +02:00
committed by GitHub
co-authored by Renn F
parent d1f9d21a68
commit 4585a248ce
4 changed files with 972 additions and 20 deletions
+264 -1
View File
@@ -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
# --------------------------------------------------------------------------- #
+297 -2
View File
@@ -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")