mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(release): close the 0.19.0 scan findings — sandbox mongo tag, flow-verb timeout walls, video hardening (#329)
- mongo:8-alpine → mongo:8 (tag never existed; a mongo-opted project could spawn no agents) + a Docker Hub tag-existence e2e guard for every sandbox engine - flow-verb timeouts at both walls: shared SLOW_VERBS policy (i_am_done / submit_up / submit_root / open_pr / i_will_work_on get the 900s server budget); the MCP client now outlasts the server budget (+10s headroom, orchestrator-injected env) so agents receive the middleware's clean 504 envelope instead of dying at the old flat 30s client timeout - cancellation safety: the quality gate kills+reaps its child on CancelledError; create_pr records the PR via a shield-with-wait-out helper so the write can neither be skipped nor race get_db's rollback - video engine: renderer sidecar isolated on a render-only network, 2g/2cpu caps, 570s render watchdog with exit-on-hang, 512MB tar decompression cap, CEO notification on terminal render failure, reject under the approve mutex (fail-closed on Redis-down) - dead python-jose dependency removed (drops ecdsa and its unfixable Minerva advisory PYSEC-2026-1325); panel --font-mono now a real monospace stack Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,7 @@ mock the network and filesystem boundaries.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -512,6 +513,122 @@ async def test_create_pr_returns_pr_dict() -> None:
|
||||
assert out["is_root_pr"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_records_pr_despite_cancellation_after_post() -> None:
|
||||
"""A cancellation landing after the GitHub POST succeeds but before the
|
||||
local record commits must not lose the record: asyncio.shield lets
|
||||
_record_pr_atomically finish, the cancellation still propagates."""
|
||||
project_id = uuid4()
|
||||
fake_task = MagicMock(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
assigned_to=uuid4(),
|
||||
title="Add login",
|
||||
description="A short description",
|
||||
)
|
||||
fake_project = MagicMock(slug="roboco")
|
||||
svc = _service()
|
||||
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
|
||||
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
|
||||
|
||||
recorded = {"done": False}
|
||||
|
||||
async def _slow_record(*_args: object, **_kwargs: object) -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
recorded["done"] = True
|
||||
|
||||
_bind(svc, "_record_pr_atomically", _slow_record)
|
||||
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.is_success = True
|
||||
fake_resp.status_code = 201
|
||||
fake_resp.json.return_value = {
|
||||
"number": _EXPECTED_PR_NUMBER,
|
||||
"html_url": f"https://github.com/acme/repo/pull/{_EXPECTED_PR_NUMBER}",
|
||||
}
|
||||
_bind(svc, "_post_pr", AsyncMock(return_value=fake_resp))
|
||||
|
||||
with _patch_project_service(fake_project):
|
||||
task = asyncio.ensure_future(
|
||||
svc.create_pr("feature/backend/abc12345", parent="master", is_root_pr=True)
|
||||
)
|
||||
await asyncio.sleep(0.01) # let create_pr reach the shielded await
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
# _await_shielded waits the in-flight record out BEFORE re-raising, so
|
||||
# by the time `await task` raised, the record had already completed.
|
||||
assert recorded["done"] is True, (
|
||||
"shield must let the record finish despite cancellation"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_cancellation_waits_out_record_before_reraising() -> None:
|
||||
"""Ordering guard for _await_shielded: on cancellation the in-flight
|
||||
_record_pr_atomically must run to COMPLETION before CancelledError
|
||||
re-raises to the caller. A bare asyncio.shield detaches the write and
|
||||
re-raises immediately — the write then races get_db's rollback on the
|
||||
same AsyncSession and asyncpg raises InterfaceError ('another operation
|
||||
is in progress'), escaping as a 500 instead of the middleware's 504."""
|
||||
project_id = uuid4()
|
||||
fake_task = MagicMock(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
assigned_to=uuid4(),
|
||||
title="Add login",
|
||||
description="A short description",
|
||||
)
|
||||
fake_project = MagicMock(slug="roboco")
|
||||
svc = _service()
|
||||
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
|
||||
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
|
||||
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
|
||||
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
|
||||
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
|
||||
|
||||
order: list[str] = []
|
||||
|
||||
async def _slow_record(*_args: object, **_kwargs: object) -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
order.append("record_done")
|
||||
|
||||
_bind(svc, "_record_pr_atomically", _slow_record)
|
||||
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.is_success = True
|
||||
fake_resp.status_code = 201
|
||||
fake_resp.json.return_value = {
|
||||
"number": _EXPECTED_PR_NUMBER,
|
||||
"html_url": f"https://github.com/acme/repo/pull/{_EXPECTED_PR_NUMBER}",
|
||||
}
|
||||
_bind(svc, "_post_pr", AsyncMock(return_value=fake_resp))
|
||||
|
||||
with _patch_project_service(fake_project):
|
||||
task = asyncio.ensure_future(
|
||||
svc.create_pr("feature/backend/abc12345", parent="master", is_root_pr=True)
|
||||
)
|
||||
await asyncio.sleep(0.01) # mid-record: cancellation lands in the shield
|
||||
task.cancel()
|
||||
# pytest.raises re-raises any OTHER exception type (e.g. the
|
||||
# InterfaceError a racing rollback would surface), failing the test —
|
||||
# that is assertion (a): CancelledError and nothing else propagates.
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
# Appended synchronously right after propagation: no other coroutine
|
||||
# can run in between, so this marker coming SECOND proves the record
|
||||
# coroutine had already completed before the cancellation re-raised.
|
||||
order.append("cancel_raised")
|
||||
|
||||
assert order == ["record_done", "cancel_raised"], (
|
||||
f"record must complete BEFORE the cancellation re-raises; got {order}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pr_raises_when_branch_not_found() -> None:
|
||||
svc = _service()
|
||||
|
||||
@@ -500,14 +500,68 @@ async def test_approve_unknown_task_returns_none(db_session: AsyncSession) -> No
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> None:
|
||||
task = await _seed_video_post(db_session)
|
||||
updated = await _svc(
|
||||
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
|
||||
).reject(_id(task), "Doesn't match the release")
|
||||
with _LOCKED[0], _LOCKED[1]:
|
||||
updated = await _svc(
|
||||
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
|
||||
).reject(_id(task), "Doesn't match the release")
|
||||
assert updated is not None
|
||||
assert updated.status == TS.CANCELLED
|
||||
assert markers.get_video_reject_reason(updated) == "Doesn't match the release"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_takes_the_same_lock_approve_holds(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""A reject under the real acquire/release path still lands (mirrors the
|
||||
approve happy-path locking) — proves the mutex round-trip, not just the
|
||||
mutation."""
|
||||
task = await _seed_video_post(db_session)
|
||||
with _LOCKED[0], _LOCKED[1]:
|
||||
updated = await _svc(
|
||||
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
|
||||
).reject(_id(task), "Doesn't match the release")
|
||||
assert updated is not None
|
||||
assert updated.status == TS.CANCELLED
|
||||
_LOCKED[0].new.assert_awaited()
|
||||
_LOCKED[1].new.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_concurrent_lock_held_refuses(db_session: AsyncSession) -> None:
|
||||
"""A reject arriving while a concurrent approve holds the lock must not
|
||||
cancel a draft that may be mid-post — same refusal as approve's own
|
||||
already-in-progress case."""
|
||||
task = await _seed_video_post(db_session)
|
||||
with patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value=None)):
|
||||
result = await _svc(
|
||||
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
|
||||
).reject(_id(task), "Doesn't match the release")
|
||||
assert result is None
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TS.PENDING # never cancelled while the lock was held
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_redis_unavailable_refuses(db_session: AsyncSession) -> None:
|
||||
"""Reject fails CLOSED when Redis is unreachable, mirroring approve: an
|
||||
approve that took the lock while Redis was up stays authoritative through
|
||||
the heartbeat grace window after Redis drops, so an unlocked reject could
|
||||
CANCEL a draft that approve is mid-posting. The CEO retries once Redis is
|
||||
back."""
|
||||
task = await _seed_video_post(db_session)
|
||||
broken = MagicMock()
|
||||
broken.set = AsyncMock(side_effect=ConnectionError("redis down"))
|
||||
broken.aclose = AsyncMock()
|
||||
with patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=broken):
|
||||
result = await _svc(
|
||||
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
|
||||
).reject(_id(task), "Doesn't match the release")
|
||||
assert result is None
|
||||
await db_session.refresh(task)
|
||||
assert task.status == TS.PENDING # never cancelled without the mutex
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_held_video_posts_excludes_terminal(
|
||||
db_session: AsyncSession,
|
||||
@@ -515,7 +569,8 @@ async def test_list_held_video_posts_excludes_terminal(
|
||||
open_task = await _seed_video_post(db_session)
|
||||
rejected_task = await _seed_video_post(db_session)
|
||||
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
|
||||
await svc.reject(_id(rejected_task), "not relevant")
|
||||
with _LOCKED[0], _LOCKED[1]:
|
||||
await svc.reject(_id(rejected_task), "not relevant")
|
||||
held = await svc.list_held_video_posts()
|
||||
ids = {t.id for t in held}
|
||||
assert open_task.id in ids
|
||||
|
||||
Reference in New Issue
Block a user