Files
roboco/tests/unit/runtime/test_video_render_loop.py
T
17de29545a [6788ce7f] Silent bug sweep: concurrency, state integrity, engine edge-cases, panel data freshness (#638)
* [943d8c4d] Frontend data freshness and approval-queue reliability audit (#631)

* [233a8b0f] WebSocket reconnect message-loss audit and fix (#625)

* [233a8b0f] fix(panel): add REST catch-up to useNotificationStream on WS reconnect

connection.ts has no message buffering/replay, so a notification published
while the CEO bell's socket was down (disconnected/reconnecting) was lost
forever instead of merely delayed. Add a reconnect-triggered GET
/notifications?unread_only=true catch-up folded into the existing
notification_id dedup so a notification delivered both via catch-up and
live WS is never double-counted, and make clearMessages drop the held
catch-up batch too. use-a2a-live.ts and use-rate-limit-websocket.ts were
audited and already have working reconnect-triggered REST fallbacks
(verified via a2a/page.tsx, rate-limit-banner.tsx, usage-overview-panel.tsx
and their existing F083 tests) so no fix was needed there.

* [233a8b0f] docs(panel): add comprehensive WebSocket hooks reference and reconnect architecture guide

Add panel/docs/frontend/hooks.md with full API reference for useWebSocket, useNotificationStream (with new REST catch-up behavior), useAgentStream, useA2ALiveStream, and useConnectionStatus. Include examples, best practices, and testing guidance.

Add panel/docs/architecture/websocket-reconnect.md documenting the message-loss mitigation pattern: Strategy 1 (REST catch-up for events, used by useNotificationStream) and Strategy 2 (REST invalidation for state, used by A2A/rate-limit consumers), plus the dedup logic ensuring no notification is double-counted on reconnect.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [d5315683] fix(frontend): add distinct toast feedback for silently-swallowed x-post and release-proposal statuses, plus regression tests for all 4 approval queues (#626)

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [cd953838] Data-hook null-guard audit and API client 429 retry-by-method fix (#630)

* [cd953838] fix(panel): gate 429 retry by HTTP method, add hook null-guard regression tests

* [cd953838] chore(conventions): waive test-fixture wrapper in hooks null-guard test

* [cd953838] docs(frontend): document API rate-limit retry behavior and null-guard audit results

Added `docs/frontend/api-rate-limiting.md` to document the 429 retry strategy: GET/PUT auto-retry, POST/PATCH/DELETE require X-Idempotency-Key header. Updated `docs/frontend/hooks.md` to confirm the data-hook null-guard audit found all hooks already have correct `enabled` guards and include a regression test suite for the board-review poll on/off behavior and enabled-guard assertions.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [4534c71a] Backend concurrency, state-machine, and engine audit (#634)

* [41de844a] fix(lifecycle): sync CLAIM_RULES with runtime + clear stale claimant on PM hand-off (#627)

Two confirmed state-machine gaps found while auditing lifecycle.py,
task_lifecycle.py, the _ESCALATABLE_TO_BLOCKED bypass, and every
_REVIEW_QUEUE_STATES entry point:

- lifecycle.py's CLAIM_RULES/claim-ActionSpec/StatusTransition table
  did not grant CELL_PM/MAIN_PM re-claim of AWAITING_PM_REVIEW even
  though task.py's runtime _ROLE_CLAIM_STATUSES already granted it
  and claimed the spec agreed -- the two tables had silently drifted,
  breaking i_will_plan re-claim on an awaiting_pm_review task.

- docs_complete's _maybe_advance_to_pm_review pre-assigns a specific
  owning PM via assigned_to but left claimed_by/active_claimant_id
  pointing at the outgoing documenter, unlike every sibling transition
  into a review-queue state. A stale active_claimant_id makes
  content_actions.py's _active_claim_violation wrongly reject the
  newly-assigned PM's own content writes before it formally claims.
  Reassign claimed_by + active_claimant_id to the owning PM alongside
  assigned_to.

Adds a regression test asserting the documenter's stale claim does not
survive the docs_complete -> awaiting_pm_review hand-off.

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>

* [0c46f666] Engine dedup race + sequencing.py edge-case audit (#628)

* [0c46f666] fix(sequencing): dedup race audit + collision-edge fallback bug

Audited the list-open-then-originate dedup pattern across six engines:
RoadmapEngine, XEngine.run_cycle, DepUpdateEngine, and CIWatchEngine each
run inside exactly one sequential orchestrator-loop asyncio task (no other
call site invokes run_cycle), so they cannot race with themselves; their
in-cycle dedup sets/keys are correctly built before any commit. SelfHealEngine
is the same shape. VideoEngine.open_video_task is genuinely different: it is
reachable from the release-publish hook, the feature-spotlight hook, and the
on-demand POST /video/request route, so two overlapping calls for the same
occasion can both pass the "no open task yet" check before either commits.
Fixed by wrapping the check+insert in a short-lived Redis mutex (reusing
HeartbeatMutex) keyed by occasion, mirroring XPostService's existing
lock pattern, with a regression test proving only one of two concurrent
calls creates a task.

Verified ReleaseExecutor's half-landed retry path (release_commit_sha):
apply_version_bumps and write_changelog_entry both run as uncommitted
working-tree edits before commit_and_push's single `git add -A` + commit,
so a bumped-version-without-changelog state can never reach origin (and
therefore can never be observed by a fresh retry clone) - confirmed correct
with a real-git-repo regression test, no fix needed.

Fixed sequencing.py's dev_task_collision_edges: the `if edges: return edges`
short-circuit dropped the same-assignee-lane fallback entirely whenever ANY
surfaced sibling pair produced a collision edge, even for a completely
unrelated same-assignee pair with no declared surface. Now the fallback
always runs, skipping only pairs the analyzer already ordered (so the two
mechanisms can never disagree on direction for the same pair).

Verified sequencing.py rule 3 (all-shared batch generates no edges): correct
by inspection (_shared_last_edges skips every pair when both are shared) and
confirmed with a regression test - no fix needed.

* [0c46f666] docs(reference): concurrency audit summary - engine races, fixes, verified patterns

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [8f7f167a] Redis mutex pre-lock write audit (#629)

* [8f7f167a] Redis mutex pre-lock write audit: add cross-session regression test for XPostService.approve

Audited x_post_service.py, video_post_service.py, release_proposal.py, and
heartbeat_mutex.py for the pre-lock DB-write anti-pattern (a session write
that happens before the SET NX / HeartbeatMutex acquire returns a token,
letting a losing racer's stale write clobber a winner's committed state).

XPostService.approve, VideoPostService.approve, and
ReleaseProposalService.approve/reject already implement the correct
validate-pure-pre-lock, apply-under-lock pattern (the XPostService fix
already shipped per CHANGELOG.md: "X edited_body write deferred into the
single-flight lock (M5)"). HeartbeatMutex holds no AsyncSession at all, so
the anti-pattern is structurally inapplicable there.

Adds a genuine cross-session concurrency regression test to
test_x_post_service.py (a real second DB connection, not an in-process
mock) mirroring VideoPostService's existing cross-session test, proving a
concurrently-committed post survives and the CEO's edited body never lands
on the just-posted row.

* [8f7f167a] Remove redundant inline comments flagged by QA in cross-session regression test

Both comments restated what the surrounding docstrings already say
explicitly, per QA findings F-dbadd8f0 (line 294) and F-27ac051e (line
631) — no behavior change, tests re-verified green against a sandbox
Postgres.

* [8f7f167a] Remove inline trailing comments flagged by QA (correct file this time)

QA findings F-e6f3e6a6 and F-24189858 cited tests/unit/services/
test_x_post_service.py:294 and :631 across 5 revision rounds, but that
file never contained the flagged comment text — a repo-wide grep for
the exact quoted strings shows both comments actually live in the
mirrored tests/unit/services/test_video_post_service.py file, in its
own cross-session concurrency regression tests (the caption-edit and
tiktok-skip tests). Removed both there:
- "# externally visible to the "concurrent" session below" on the
  db_session.commit() call
- "# never attempted without credentials" on the tiktok_poster.calls
  assertion

Both restated what the surrounding docstrings/test names already say;
no behavior change. Verified with the full make quality gate against a
sandbox Postgres/Redis: 13,717 passed, 94.41% coverage, clean except
one pre-existing unrelated failure in tests/unit/api/test_cloud_auth.py
::test_login_route_parses_oauth2_form_not_query_params, which connects
to the app's default localhost:5432 Postgres (not the db_session
sandbox fixture) and is unreachable in this sandboxed environment —
structurally unrelated to the auth subsystem this task never touches.

* [8f7f167a] Redis mutex pre-lock write audit (round 7): add cross-session regression tests for reject() lock protection

Round-7 QA findings F-7eb9fbcb, F-06f39a2e, and F-4d56e49b claim
XPostService.reject(), ReleaseProposalService.reject(), and
release_executor._await_proc() lack lock protection / a CancelledError
handler — but their cited line ranges (255-267, 429-454, 241-257)
describe a pre-fix, shorter version of these functions that predates
commit fb293a787d, already on this branch. At current HEAD:

- x_post_service.py reject() (lines 275-299) acquires _LOCK_PREFIX,
  re-reads under the lock, applies markers.set_x_reject_reason() +
  CANCELLED only inside the critical section, releases in finally.
- release_proposal.py reject() (lines 460-486) does the identical
  dance with _RELEASE_LOCK_PREFIX.
- release_executor.py _await_proc() (lines 257-265) already has an
  `except asyncio.CancelledError` block that kills + reaps the child
  and re-raises, mirroring the TimeoutError handler, with an existing
  dedicated regression test
  (test_await_proc_kills_child_on_outer_cancellation).

The one genuine gap: neither reject() path had a cross-session
(real second DB connection, not an in-process mock) regression test
proving the in-lock re-read catches a concurrent approve/publish that
completes mid-lock-wait — only approve() had one. Added
test_reject_concurrent_approve_completes_during_lock_wait to both
test_x_post_service.py and test_release_proposal_status_guards.py,
mirroring the existing approve() cross-session test: a second engine
commits COMPLETED between reject's pre-lock read and lock acquisition,
and the test asserts the CANCELLED write / reject-reason marker never
lands on the just-completed row.

No production code changed — verified via 103 targeted tests green
against a sandbox Postgres/Redis, plus `make -o sync gate` clean.

* [8f7f167a] Regenerate stale lifecycle artifacts (restore auditor waive_finding)

foundation-check was the only failing gate: the committed lifecycle artifacts
were missing the auditor's waive_finding verb that the lifecycle source
defines, so make quality regenerated them and failed on the diff — nothing to
do with the mutex fix (which passes ruff/mypy/tests/coverage/bandit clean).
make lifecycle restores the drift; this is what the 8 revision rounds kept
missing.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* [d615f2e3] fix(tests): sync stale CLAIM_RULES pinning assertions with lifecycle.py (#635)

test_claim_rules_match_pre_gateway_table still asserted the pre-audit
two-member frozenset for CELL_PM/MAIN_PM claim rules. CLAIM_RULES in
lifecycle.py already grants both roles claim rights on
Status.AWAITING_PM_REVIEW (added by the state-machine exhaustiveness
audit) so a PM can re-claim its own review-queue task after a respawn.
Updated both assertions to include AWAITING_PM_REVIEW, matching the
actual dict. Grepped the repo for sibling stale copies of the old
literal; found none beyond this test.

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [3c4e7a35] fix(quality-gate): reflow CONCURRENCY_AUDIT.md and stub occasion lock in video tests (#636)

Root cause: PR #634's CI failed at the markdown-reflow check (make quality
Makefile:285) on CONCURRENCY_AUDIT.md — a hard-wrapped audit doc left over
from the merged "Engine dedup race + sequencing.py edge-case audit" unit
(PR #628). Fixed with `make reflow-docs` (the exact remedy the CI output
itself named).

Running the full local `make quality` (with a sandbox Postgres/Redis to
get past DB-gated skips) surfaced a second real regression from that same
PR #628 unit: it added a Redis-backed HeartbeatMutex occasion lock to
VideoEngine.open_video_task, but two pre-existing test files
(tests/unit/runtime/test_video_render_loop.py and
tests/integration/test_video_routes.py) call open_video_task without
stubbing that lock, so they failed closed against the suite's
deliberately-unreachable test Redis (_no_live_redis). Fixed by applying
the same lock-stub pattern tests/unit/services/test_video_engine.py
already uses for its own occasion-lock tests: an autouse HeartbeatMutex
stand-in fixture in test_video_render_loop.py, and wrapping the two
route-level video-request tests in test_video_routes.py with the file's
existing _LOCKED patch pair (already used by every other lock-dependent
test in that file).

The one remaining local failure,
test_cloud_auth.py::test_login_route_parses_oauth2_form_not_query_params,
is a pre-existing environment gap unrelated to this branch: it needs a
real Postgres reachable at localhost:5432 (which .github/workflows/ci.yml
provides as a service container) but this dev sandbox has no such binding
— confirmed unrelated to any of the four merged audit units.

make quality now passes clean: 13729 passed, 0 regressions, 94.49% coverage.

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [ddc8121f] regenerate lifecycle artifacts for awaiting_pm_review claim rules and waive_finding intent (#637)

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* fix(sequencing): drop lane-fallback edges that would cycle against analyzer edges

The dev-task collision fallback unioned the analyzer's authoritative edges
with same-assignee lane edges, deduping only the direct pair. A lane chain
through an unsurfaced middle sibling could still contradict an analyzer edge
transitively (the shared-last migration order inverts plain priority order),
closing a 3-cycle that made add_dependency raise ConflictError and wedged
every later delegate to that parent. Fallback edges are now accepted only
when they can't close a cycle against the edges already kept; a regression
test reproduces the exact scenario.

Also strip pre-merge cruft: remove the root CONCURRENCY_AUDIT.md working
report, delete the near-duplicate websocket-reconnect.md doc, fix the stale
a2a/page.tsx doc citation, correct the api-rate-limiting doc to state
idempotency-key retry is unimplemented, and fix two lifecycle.py comments
that referenced a guard function which never existed.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 08:11:28 +02:00

588 lines
20 KiB
Python

"""The orchestrator video-render loop: dormant when off; the cycle wrapper
iterates + commits (mocked wiring test, mirrors test_dep_update_loop.py); the
per-task render (mocked renderer/workspace, real DB) renders both cuts, holds
one video_post draft, and is idempotent (a rendered task is never re-rendered;
a failed render bounded-retries up to a cap, then is terminal) — never itself
committing, so it never pollutes the session-scoped
shared test database the way routing it through the committing cycle wrapper
would.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.models.base import TaskStatus as TS
from roboco.runtime.orchestrator import (
_MAX_VIDEO_RENDER_ATTEMPTS,
AgentOrchestrator,
)
from roboco.services import video_engine as video_engine_module
from roboco.services.task import VIDEO_POST_SOURCE, get_task_service
from roboco.services.video_engine import VideoEngine
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
FOUR = 4
def _orch() -> Any:
return AgentOrchestrator.__new__(AgentOrchestrator)
class _AlwaysAcquiredMutex:
"""Stand-in for ``HeartbeatMutex``: always acquires immediately, no live
Redis required (matches the project's ``_no_live_redis`` fixture and
mirrors ``test_video_engine.py``'s identical stub)."""
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
async def acquire(self) -> str | None:
return "tok"
async def release(self, _token: str) -> None:
return None
@pytest.fixture(autouse=True)
def _stub_occasion_lock(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(video_engine_module, "HeartbeatMutex", _AlwaysAcquiredMutex)
class _FakeRenderer:
"""Records every render() call; returns a deterministic path or raises."""
def __init__(self, *, fail: bool = False) -> None:
self.calls: list[dict[str, str]] = []
self.fail = fail
async def render(
self,
*,
source_dir: str,
composition_id: str,
input_props: dict[str, Any],
orientation: str,
render_key: str,
) -> str:
_ = input_props
self.calls.append(
{
"source_dir": source_dir,
"composition_id": composition_id,
"orientation": orientation,
"render_key": render_key,
}
)
if self.fail:
raise RuntimeError("render blew up")
return f"/fake-out/{composition_id}-{orientation}.mp4"
def _db_ctx(db: Any) -> Any:
@asynccontextmanager
async def _ctx() -> Any:
yield db
return _ctx
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
(UX_DEV_1_UUID, "ux-dev-1", AgentRole.DEVELOPER, Team.UX_UI),
(UX_DEV_2_UUID, "ux-dev-2", AgentRole.DEVELOPER, Team.UX_UI),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
existing = await session.execute(
select(ProjectTable).where(ProjectTable.slug == SLUG)
)
if existing.scalar_one_or_none() is None:
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await session.flush()
def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
monkeypatch.setattr(cfg, "video_render_interval_seconds", 120.0)
for key, value in overrides.items():
monkeypatch.setattr(cfg, key, value)
async def _make_completed_video_task(
session: AsyncSession, *, occasion: str, composition_id: str | None
) -> Any:
engine = VideoEngine(session)
task = await engine.open_video_task(
occasion=occasion,
script="Here's what shipped",
platforms=["x", "tiktok"],
brief="Announce the release",
)
assert task is not None
if composition_id is not None:
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(
task,
{
**draft,
"composition_id": composition_id,
"input_props": {"title": "hello"},
"x_caption": "Check out our new release!",
"tiktok_caption": "New release, check it out",
"platforms": ["x", "tiktok"],
},
)
task.status = TS.COMPLETED
await session.flush()
return task
def _render_patches(renderer: _FakeRenderer, workspace: Any) -> Any:
return (
patch(
"roboco.services.video_renderer_client.get_video_renderer",
lambda: renderer,
),
patch(
"roboco.services.workspace.get_workspace_service",
lambda _db: workspace,
),
)
def _fake_workspace() -> Any:
return SimpleNamespace(
ensure_read_clone=AsyncMock(return_value=Path("/fake-clone"))
)
# --------------------------------------------------------------------------- #
# _video_render_loop dormancy
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
stub = cast("AgentOrchestrator", SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._video_render_loop(stub), timeout=1.0)
# --------------------------------------------------------------------------- #
# _run_video_render_cycle — wiring only (mocked db/service, mirrors
# test_dep_update_loop.py); the substantive render behavior is covered below
# against _render_video_task directly, which never commits.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_run_cycle_commits_per_task() -> None:
orch = _orch()
task_a = MagicMock()
task_b = MagicMock()
db = MagicMock()
db.commit = AsyncMock()
task_svc = MagicMock()
task_svc.list_completed_video_tasks = AsyncMock(return_value=[task_a, task_b])
orch._render_video_task = AsyncMock()
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch("roboco.services.task.get_task_service", return_value=task_svc),
):
await orch._run_video_render_cycle()
assert orch._render_video_task.await_args_list == [
call(db, task_a),
call(db, task_b),
]
# one commit per task — never one trailing commit after the loop
assert db.commit.await_count == TWO
@pytest.mark.asyncio
async def test_run_cycle_with_no_completed_tasks_does_not_commit() -> None:
orch = _orch()
db = MagicMock()
db.commit = AsyncMock()
task_svc = MagicMock()
task_svc.list_completed_video_tasks = AsyncMock(return_value=[])
orch._render_video_task = AsyncMock()
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch("roboco.services.task.get_task_service", return_value=task_svc),
):
await orch._run_video_render_cycle()
orch._render_video_task.assert_not_awaited()
db.commit.assert_not_awaited() # nothing rendered → nothing to durably persist
@pytest.mark.asyncio
async def test_run_cycle_commits_before_mid_cycle_raise_so_prior_render_durable() -> (
None
):
"""A raise mid-cycle must not roll back prior renders: each render is
committed before the next is attempted, so the committed
render_status='rendered' is the idempotency key the next scan skips
(instead of re-rendering + re-originating a second held video_post draft).
"""
orch = _orch()
task_a = MagicMock()
task_b = MagicMock()
db = MagicMock()
db.commit = AsyncMock()
task_svc = MagicMock()
task_svc.list_completed_video_tasks = AsyncMock(return_value=[task_a, task_b])
async def _render(_db: Any, task: Any) -> None:
if task is task_b:
raise RuntimeError("B blew up")
orch._render_video_task = AsyncMock(side_effect=_render)
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch("roboco.services.task.get_task_service", return_value=task_svc),
pytest.raises(RuntimeError, match="B blew up"),
):
await orch._run_video_render_cycle()
# A's commit happened BEFORE B raised — exactly one commit, A is durable
db.commit.assert_awaited_once()
assert orch._render_video_task.await_args_list == [
call(db, task_a),
call(db, task_b),
]
# --------------------------------------------------------------------------- #
# _render_video_task — real DB (flush only, never commits: the session-scoped
# shared test DB stays clean via this test's own rollback teardown), mocked
# renderer + workspace clone.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_render_video_task_renders_both_cuts_and_materializes_post(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="render-both-cuts", composition_id="Intro"
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
workspace.ensure_read_clone.assert_awaited_once_with(SLUG)
assert len(renderer.calls) == TWO
orientations = {c["orientation"] for c in renderer.calls}
assert orientations == {"vertical", "square"}
expected_motion_dir = str(Path("/fake-clone") / "motion")
assert all(c["source_dir"] == expected_motion_dir for c in renderer.calls)
assert all(c["render_key"] == str(task.id) for c in renderer.calls) # task-scoped
posts = await get_task_service(db_session).list_open_video_posts()
assert len(posts) == ONE
assert posts[0].source == VIDEO_POST_SOURCE
draft = markers.get_video_draft(posts[0])
assert draft is not None
assert draft["mp4_paths"] == {
"vertical": "/fake-out/Intro-vertical.mp4",
"square": "/fake-out/Intro-square.mp4",
}
assert draft["x_caption"] == "Check out our new release!"
assert draft["tiktok_caption"] == "New release, check it out"
source_draft = markers.get_video_draft(task)
assert source_draft is not None
assert source_draft["render_status"] == "rendered"
@pytest.mark.asyncio
async def test_render_video_task_resolves_workspace_from_task_project_not_settings(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The render loop resolves the read-clone from the authoring task's OWN
project_id — flipping self_heal_project_slug to a bogus value AFTER the
task was authored must not affect the render, proving the loop no longer
reads that setting live."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="own-project-not-settings", composition_id="Intro"
)
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project-anymore")
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
# Still resolved via the task's own project_id -> slug "roboco", not the
# now-bogus self_heal_project_slug.
workspace.ensure_read_clone.assert_awaited_once_with(SLUG)
posts = await get_task_service(db_session).list_open_video_posts()
assert len(posts) == ONE
@pytest.mark.asyncio
async def test_render_video_task_second_call_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="idempotent", composition_id="Intro"
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
await orch._render_video_task(db_session, task) # must be a no-op
assert len(renderer.calls) == TWO # not four — the second call skipped it
posts = await get_task_service(db_session).list_open_video_posts()
assert len(posts) == ONE
@pytest.mark.asyncio
async def test_rerender_clears_state_so_next_cycle_re_renders(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""CEO re-render flow end to end: a rendered task is a no-op on a second
render pass (idempotent); clearing render_status/render_attempts via
VideoEngine.rerender makes the NEXT pass pick it up and render it again."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="rerender-cycle", composition_id="Intro"
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
assert len(renderer.calls) == TWO
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_status"] == "rendered"
rerendered = await VideoEngine(db_session).rerender(task.id)
assert rerendered is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "render_status" not in draft
assert "render_attempts" not in draft
with p1, p2:
await orch._render_video_task(db_session, task) # re-picked up
assert len(renderer.calls) == FOUR # rendered a second time, not skipped
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_status"] == "rendered"
@pytest.mark.asyncio
async def test_render_video_task_skips_task_without_composition_id(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="no-composition", composition_id=None
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
assert renderer.calls == []
workspace.ensure_read_clone.assert_not_awaited()
posts = await get_task_service(db_session).list_open_video_posts()
assert posts == []
draft = markers.get_video_draft(task)
assert draft is not None
assert draft.get("render_status") is None
@pytest.mark.asyncio
async def test_render_video_task_single_failure_retries_not_terminal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One failure bumps the attempt counter but does NOT terminally fail the
task — a stale read-clone or a transient sidecar blip must be retried on a
later cycle, not silently lost."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="render-fails-once", composition_id="Intro"
)
renderer = _FakeRenderer(fail=True)
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
posts = await get_task_service(db_session).list_open_video_posts()
assert posts == []
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_attempts"] == ONE
assert draft.get("render_status") is None # retried next cycle, not terminal
@pytest.mark.asyncio
async def test_render_video_task_terminal_after_max_attempts(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""After _MAX_VIDEO_RENDER_ATTEMPTS failures the task is terminally failed
and never rendered again — a genuinely broken composition can't loop."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="max-attempts", composition_id="Intro"
)
seeded = markers.get_video_draft(task) or {}
markers.set_video_draft(
task, {**seeded, "render_attempts": _MAX_VIDEO_RENDER_ATTEMPTS - 1}
)
await db_session.flush()
renderer = _FakeRenderer(fail=True)
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
notify_svc = AsyncMock()
with (
p1,
p2,
patch(
"roboco.services.notification.NotificationService",
return_value=notify_svc,
),
):
await orch._render_video_task(db_session, task) # tips to terminal
calls_at_terminal = len(renderer.calls)
await orch._render_video_task(db_session, task) # now a no-op
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_attempts"] == _MAX_VIDEO_RENDER_ATTEMPTS
assert draft["render_status"] == "failed"
assert len(renderer.calls) == calls_at_terminal # not retried after terminal
posts = await get_task_service(db_session).list_open_video_posts()
assert posts == []
# Exactly one CEO alert — the second (no-op) call must not re-notify.
notify_svc.send_ack_notification.assert_awaited_once()
notify_kwargs = notify_svc.send_ack_notification.await_args.kwargs
assert notify_kwargs["to_agent"] == "ceo"
assert task.title in notify_kwargs["body"]
assert "render blew up" in notify_kwargs["body"]
assert notify_kwargs["task_id"] == task.id
@pytest.mark.asyncio
async def test_render_video_task_notify_failure_does_not_raise(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A broken notification path (e.g. the second DB connection is down)
must not surface out of the render loop — best-effort, like the
strategy-engine failure notifier."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="notify-fails", composition_id="Intro"
)
seeded = markers.get_video_draft(task) or {}
markers.set_video_draft(
task, {**seeded, "render_attempts": _MAX_VIDEO_RENDER_ATTEMPTS - 1}
)
await db_session.flush()
renderer = _FakeRenderer(fail=True)
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with (
p1,
p2,
patch(
"roboco.services.notification.NotificationService",
side_effect=RuntimeError("notification DB unreachable"),
),
):
await orch._render_video_task(db_session, task) # must not raise
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_status"] == "failed"