mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [env-bran] EnvSyncEngine: orchestrator-side prod→dev cascade (default-off) - EnvSyncEngine mirrors CiWatchEngine: cascade ladder_pairs top-down via GitHub merges API; clean→auto-push lower rung, conflict→one sync PR + tracked MAIN_PM task + stop. Never pushes prod (lower rung is never prod by construction). - GitService.sync_env_branch (merges API) + open_sync_pr (idempotent) + _env_merge_status/_post_sync_pr helpers (constants for 201/204/409). - TaskService.ENV_SYNC_SOURCE + list_open_env_sync_tasks (per-repo dedup). - config env_sync_enabled/_interval_seconds(1800)/_max_open_tasks(3)/_max_per_cycle(1). - Orchestrator 4-touch registration + _load_env_sync_set (ladder+token opt-in). - Feature-flags card + settings FEATURE_FLAGS entry for ROBOCO_ENV_SYNC_ENABLED. * [env-bran] Panel: environment ladder editor + types + validation - EnvironmentRung type + environments on Project/ProjectCreate/ProjectUpdate. - EnvironmentLadderEditor (plain useState, add/remove/up-down reorder, head/ prod labels) reused by create + edit project dialogs. - validateLadder (non-empty name+branch, no duplicate branches) shared, toast.error on submit; empty editor => null => inherits default_branch shim. - default_branch input kept with override-hint; API client passthrough. - 6 unit tests for validateLadder. * [env-bran] Tests + gate green: env ladder, EnvSyncEngine, promotion chain - tests/unit/models/test_env_branches.py: shim, head/prod, ladder_pairs, promotion_chain, normalize (20 tests) - tests/integration/services/test_env_sync_engine.py: cascade clean/conflict/ missing_ref/tokenless/degenerate/caps/dedup/disabled (9 tests, DB) - tests/integration/test_migration_env_branches.py: 073 defaults null + round-trip - tests/unit/services/test_release_executor*.py: add env_chain=[] to _ReleaseContext constructions (promotion_chain field is now required) - tests/unit/runtime/test_orchestrator_shutdown_drain.py: register _env_sync_task in the stop()-drain fixture (new named background loop) - roboco/services/git.py: revert _project_head_branch rename back to _project_default_branch (modify-in-place per plan); the rename in the consumers commit broke ~15 unit-test mocks that bind the original name - roboco/services/env_sync_engine.py + models/env_branches.py: ruff format - roboco/api/schemas/project.py: trailing-newline format Backend gate green (13013 passed / 439 skipped), mypy clean, ruff clean. Panel gate green (typecheck/lint/522 tests). * [env-bran] fix: add env_chain to _ReleaseContext in e2e smoke (CI red) The release-executor promotion_chain change made _ReleaseContext.env_chain required. I fixed the three unit/release test files but missed the construction in tests/e2e_smoke/test_background_engines.py:98 — my local gate ran 'mypy roboco/' (excludes tests/) and I skipped 'make e2e-smoke', so CI's mypy-on-tests + the e2e runtime job caught it instead of me. Verified locally with the CI-equivalent gates: uv run mypy roboco/ tests/ -> 1170 files, clean ROBOCO_E2E_SMOKE=1 uv run pytest tests/e2e_smoke -> 50 passed, 1 skipped * [env-bran] fix: extract _ensure_prod_fetched to clear xenon rank C (CI red) _production_assess grew past xenon --max-absolute B (rank C) when the env-branches prod-tip fetch added an if/try/except branch. Extracted the fetch-with-fallback into _ensure_prod_fetched (degan+fetch paths), moved _run_git to the module-level import. Local make quality green (all gates incl xenon/vulture/deptry/import-linter/foundation-check). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
256 lines
9.2 KiB
Python
256 lines
9.2 KiB
Python
"""Drain ``_bg_tasks`` on shutdown so fire-and-forget writes (respawn_tracker
|
|
upserts, audit-log writes, intake first-message delivery) are not abandoned.
|
|
|
|
Invariant: ``Orchestrator.stop()`` drains ``_bg_tasks`` with a bounded timeout —
|
|
short DB writes finish before the process exits (data preserved), a stuck task
|
|
is cancelled once the deadline passes (can't hang shutdown). The ``stop_agent``
|
|
loop is wrapped so one agent's stop error can't skip the drain.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from roboco.models.runtime import AgentInstance
|
|
from roboco.runtime.orchestrator import (
|
|
_SHUTDOWN_DRAIN_TIMEOUT_SECONDS,
|
|
AgentOrchestrator,
|
|
)
|
|
|
|
# Floor encoding the logical-regression guard: a drain deadline below this
|
|
# would risk dropping a legitimate short DB write (an upsert that needs a
|
|
# second under load) before it commits — the exact data loss this fix targets.
|
|
# Named (not magic) for ruff PLR2004.
|
|
_MIN_DRAIN_TIMEOUT = 3.0
|
|
|
|
|
|
def _make_orchestrator() -> AgentOrchestrator:
|
|
"""AgentOrchestrator with constructor I/O skipped; stop() deps ready.
|
|
|
|
``stop()`` cancels the named loop tasks (all None here → no-op) and the
|
|
agents in ``_instances`` (empty here), then must drain ``_bg_tasks``.
|
|
"""
|
|
with patch.object(AgentOrchestrator, "__init__", return_value=None):
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
orch._instances = {}
|
|
orch._bg_tasks = set()
|
|
orch._pm_respawn_tracker = {} # #74: stop() flushes this after the drain
|
|
# Every named background loop ``stop()`` cancels — None makes each a no-op
|
|
# so the test exercises ONLY the _bg_tasks drain.
|
|
for attr in (
|
|
"_health_task",
|
|
"_dispatcher_task",
|
|
"_sweeper_task",
|
|
"_rate_limit_probe_task",
|
|
"_strategy_engine_task",
|
|
"_external_pr_poll_task",
|
|
"_self_heal_task",
|
|
"_ci_watch_task",
|
|
"_dep_update_task",
|
|
"_env_sync_task",
|
|
"_release_manager_task",
|
|
"_x_mentions_task",
|
|
"_roadmap_engine_task",
|
|
"_x_feature_spotlight_task",
|
|
"_video_render_task",
|
|
"_vault_intake_task",
|
|
"_vault_janitor_task",
|
|
"_vault_kb_task",
|
|
):
|
|
setattr(orch, attr, None)
|
|
return orch
|
|
|
|
|
|
def test_shutdown_drain_timeout_is_named_module_constant() -> None:
|
|
assert isinstance(_SHUTDOWN_DRAIN_TIMEOUT_SECONDS, int | float)
|
|
assert _SHUTDOWN_DRAIN_TIMEOUT_SECONDS > 0
|
|
|
|
|
|
def test_shutdown_drain_timeout_is_generous() -> None:
|
|
"""A short DB upsert under load can legitimately take a moment; the drain
|
|
deadline must not drop it. This guards the logical regression: a too-short
|
|
drain would silently lose the very writes it exists to preserve."""
|
|
assert _SHUTDOWN_DRAIN_TIMEOUT_SECONDS >= _MIN_DRAIN_TIMEOUT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_drains_completing_bg_task_before_returning() -> None:
|
|
"""A bg task that finishes quickly MUST complete (its side effect observed)
|
|
before ``stop()`` returns. Without the drain, ``stop()`` returns immediately
|
|
and the task is abandoned mid-flight — the data-loss tail."""
|
|
orch = _make_orchestrator()
|
|
ran: list[bool] = []
|
|
|
|
async def _completes() -> None:
|
|
await asyncio.sleep(0.01)
|
|
ran.append(True)
|
|
|
|
orch._bg_tasks.add(asyncio.create_task(_completes()))
|
|
|
|
await asyncio.wait_for(orch.stop(), timeout=5.0)
|
|
|
|
assert ran == [True], "completing bg task was abandoned by stop()"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_does_not_hang_on_stuck_bg_task(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A bg task that never completes MUST NOT hang shutdown past the drain
|
|
deadline — it is cancelled once the drain times out. Without the drain,
|
|
a stuck bg task would let ``stop()`` (and thus the process) hang forever.
|
|
|
|
Deterministic: the drain deadline is patched tiny so a bounded fail-close is
|
|
asserted in well under a second, never relying on the real 5s default."""
|
|
monkeypatch.setattr(
|
|
"roboco.runtime.orchestrator._SHUTDOWN_DRAIN_TIMEOUT_SECONDS", 0.05
|
|
)
|
|
orch = _make_orchestrator()
|
|
|
|
async def _hangs() -> None:
|
|
await asyncio.Future() # never resolves
|
|
|
|
stuck = asyncio.create_task(_hangs())
|
|
orch._bg_tasks.add(stuck)
|
|
|
|
await asyncio.wait_for(orch.stop(), timeout=2.0)
|
|
|
|
assert stuck.cancelled(), "stuck bg task was not cancelled by the drain"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_failing_agent_does_not_skip_drain() -> None:
|
|
"""If one agent's ``stop_agent`` raises, the drain must still run —
|
|
otherwise a single bad agent would re-introduce the data-loss tail for every
|
|
in-flight bg write. The completing bg task should still finish."""
|
|
orch = _make_orchestrator()
|
|
orch._instances["bad-agent"] = AgentInstance(agent_id="bad-agent")
|
|
|
|
async def _raises(_aid: str, **_kw: Any) -> None:
|
|
raise RuntimeError("boom")
|
|
|
|
patch.object(orch, "stop_agent", _raises).start()
|
|
|
|
ran: list[bool] = []
|
|
|
|
async def _completes() -> None:
|
|
await asyncio.sleep(0.01)
|
|
ran.append(True)
|
|
|
|
orch._bg_tasks.add(asyncio.create_task(_completes()))
|
|
|
|
await asyncio.wait_for(orch.stop(), timeout=5.0)
|
|
|
|
assert ran == [True], "failing stop_agent skipped the drain (data lost)"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_is_idempotent_double_call_is_noop() -> None:
|
|
"""stop() is idempotent: the lifespan path and bootstrap's finally block both
|
|
call it, so the second call must be a clean no-op — not a re-drain or re-stop
|
|
of already-stopped agents — guarded by ``_stopped``."""
|
|
orch = _make_orchestrator()
|
|
real_drain = orch._drain_bg_tasks
|
|
drain_calls = 0
|
|
|
|
async def counting_drain() -> None:
|
|
nonlocal drain_calls
|
|
drain_calls += 1
|
|
await real_drain()
|
|
|
|
# Override via an Any-typed view so the assignment bypasses mypy's
|
|
# method-assign check while staying a plain attribute write (no setattr).
|
|
orch_any: Any = orch
|
|
orch_any._drain_bg_tasks = counting_drain
|
|
|
|
await orch.stop()
|
|
assert drain_calls == 1, "first stop() drained the bg tasks"
|
|
assert orch._stopped is True
|
|
|
|
await orch.stop() # safety-net double-call (lifespan already stopped it)
|
|
assert drain_calls == 1, "second stop() must not re-drain (idempotent no-op)"
|
|
assert orch._stopped is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #74: stop() flushes the authoritative in-memory respawn snapshot AFTER the
|
|
# bounded drain so a deadline-cancelled fire-and-forget persist can't leave the
|
|
# durable count lagging the in-memory counter (which would re-burn the strike
|
|
# threshold on the next restart).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BE_PM_TID = "11111111-1111-1111-1111-111111111111"
|
|
_FE_PM_TID = "22222222-2222-2222-2222-222222222222"
|
|
_EXPECTED_FLUSHES = 2 # two seeded respawn rows → two shutdown persists
|
|
|
|
|
|
def _respawn_record(count: int) -> dict[str, Any]:
|
|
return {
|
|
"count": count,
|
|
"last_status": "blocked",
|
|
"last_check": None,
|
|
"tracing_resets": 0,
|
|
"notified": False,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_flushes_respawn_tracker_after_drain() -> None:
|
|
"""#74: stop() writes every in-memory respawn row after the drain, carrying
|
|
the latest count so the durable counter matches memory on the next restart."""
|
|
orch = _make_orchestrator()
|
|
orch_any: Any = orch
|
|
orch_any._pm_respawn_tracker = {
|
|
("be-pm", _BE_PM_TID): _respawn_record(4),
|
|
("fe-pm", _FE_PM_TID): _respawn_record(2),
|
|
}
|
|
persist = AsyncMock()
|
|
orch_any._persist_respawn_record = persist
|
|
|
|
await asyncio.wait_for(orch.stop(), timeout=5.0)
|
|
|
|
assert persist.await_count == _EXPECTED_FLUSHES
|
|
keys = {(c.args[0], c.args[1]) for c in persist.await_args_list}
|
|
assert keys == {("be-pm", _BE_PM_TID), ("fe-pm", _FE_PM_TID)}
|
|
counts = {c.args[0]: c.args[2]["count"] for c in persist.await_args_list}
|
|
assert counts == {"be-pm": 4, "fe-pm": 2}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_respawn_tracker_noop_when_empty() -> None:
|
|
"""An empty tracker flushes nothing — no DB churn on a clean shutdown."""
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
orch_any: Any = orch
|
|
orch_any._pm_respawn_tracker = {}
|
|
persist = AsyncMock()
|
|
orch_any._persist_respawn_record = persist
|
|
|
|
await orch._flush_respawn_tracker()
|
|
|
|
persist.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_respawn_tracker_swallows_row_errors() -> None:
|
|
"""#74: a row whose persist raises must not skip the remaining rows or crash
|
|
shutdown — the in-memory value is gone once the process exits anyway."""
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
orch_any: Any = orch
|
|
orch_any._pm_respawn_tracker = {
|
|
("be-pm", _BE_PM_TID): _respawn_record(4),
|
|
("fe-pm", _FE_PM_TID): _respawn_record(2),
|
|
}
|
|
|
|
async def _persist(slug: str, _tid: str, _record: dict[str, Any]) -> None:
|
|
if slug == "be-pm":
|
|
raise RuntimeError("db down")
|
|
# fe-pm succeeds
|
|
|
|
orch_any._persist_respawn_record = _persist
|
|
|
|
# Must not raise — the failing row is logged and the rest still flushed.
|
|
await orch._flush_respawn_tracker()
|