Files
roboco/tests/unit/runtime/test_orchestrator_shutdown_drain.py
T
15a3e87a2f feat(vault): Obsidian vault V1 — projection core, Auditor narrative, input loop (#458)
* fix(gateway): gate review diffs against the task's real parent branch (#444)

The in-path PR-review gate's evidence diff (claim_gate_review) and the
pr_pass conventions guard derived their diff base via parent_branch_for
string surgery, which reuses the child branch's own team segment — wrong
for every cross-team hop (a frontend child of a main_pm root derives a
ref that never existed) and silently falls back to the repo default
branch, so the reviewer judged the entire inherited base-branch content
as the task's own work and failed acceptance criteria the task never
touched. Bounced a live goals-tab fix three times, unfixable by branch
surgery.

The gate now resolves the base via resolve_parent_branch (the parent
task's recorded branch_name, cross-team correct) and threads it as a new
preferred_parent override through git.diff / list_changed_files /
conventions_check_for_task — consulted only when no explicit base is
given, so the pinned literal-base contract (base="HEAD~1") and every
other diff caller (QA, doc, content) are byte-identical. Parent lookup
fails open (derived-base fallback) like the other resolve_parent_branch
call sites, and is skipped entirely while the conventions flag is off.

Also excludes .uv-cache/ and .claude/ (agent worktrees, private uv
cache) from the markdown prose scanner — both are repo-local tool dirs
whose vendored/generated files tripped make reflow-check.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* feat(vault): Obsidian vault V1 — projection core + input loop

The vault is a rebuildable projection of the DB (never a source of
truth), default-off behind ROBOCO_OBSIDIAN_VAULT_ENABLED + ROBOCO_VAULT_PATH.

Projection core: VaultWriter materializes tasks/journals/A2A/agents as
wikilinked markdown (id-suffixed stable filenames, alias-based links so
renames never break, is_private journals excluded like the RAG corpus);
event seams materialize on journal write and A2A send and touch task-note
frontmatter at the status-transition chokepoint — all best-effort, a
vault failure never blocks a verb. Shipped .obsidian config (Dataview,
Kanban, team/status graph groups) + _meta dashboards; python -m
roboco.vault rebuild/relocate (rebuild preserves the narrative section).

Auditor narrative duty: curate_vault content verb (auditor-only,
playbook-curation pattern) spawned by a dedicated root-completion hook
with its own cooldown — fully separate from _dispatch_audit_work, whose
scheduled-sweep/alert-producer revival belongs to the queued fleet task.

Input loop: VaultIntakeEngine (ROBOCO_VAULT_INTAKE_*) watches the
intake folder for #roboco-tagged notes and materializes each as ONE held
draft (confirmed_by_human=False, Secretary-owned, source=vault_note,
excluded by the dispatchers via _is_held_ceo_source) with local-model
extraction and a deterministic fallback; vault_seen_notes ledger
(migration 070) keyed on path+content-hash (the CEO-feedback callout is
stripped before hashing so the engine's own append never self-triggers);
per-cycle and open-draft caps. Nothing auto-starts.

* fix(vault): integrate with master — re-chain migration 070 onto 069, mypy-clean tests

The vault branch was cut from slave before the sequence-gate promotion,
so migration 070 chained from 068 while master already carried 069 —
two heads on merge. Master is now merged in and 070 revises 069.
Vault test files also get mypy-clean mock idioms (monkeypatch.setattr
over method assignment; await_args narrowed before access).

* fix(scripts): dedupe SKIP_DIRS again after the master merge

Master still carries the twin-merge duplicate (its dedupe hotfix is an
unmerged PR); the merge re-imported it here.

* fix(vault): reflow hard-wrapped prose in the vault asset templates

* chore(config): exclude .uv-cache and .claude from deptry's scan scope

Same repo-local tool dirs the prose scanner skips; the standalone deptry
target walks the repo root and drowned in the cache's unpacked wheels.

* fix(vault): board-review activation path for vault drafts + relocate graft

The input loop's held-artifact posture was a dead end: vault_note drafts
were unconditionally held by _is_held_ceo_source, owned by the verbless
Secretary, hidden from the panel's approval surfaces by team, and the
open-drafts cap counted them forever — the engine self-bricked after ten
notes. Vault drafts now ride the intake board-review path instead: a
tagged note becomes a PENDING Product-Owner-assigned Board draft (the
exact confirm_live_draft board shape), the board reviews it, and only
the CEO's approve_and_start makes it deliverable — never-auto-starts now
rests on the board gate, proven by tests against the real dispatchers.
The cap counts only drafts still awaiting the CEO (team==BOARD,
non-terminal), so approval and cancellation both free it.

relocate into an existing personal vault now grafts old_root/RoboCo as a
direct child (refusing loudly if RoboCo/ already exists there) and adds
only absent .obsidian/_meta files — a personal vault's config is never
clobbered. An absent destination keeps the whole-tree move.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-11 03:17:59 +02:00

253 lines
9.1 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",
"_release_manager_task",
"_x_mentions_task",
"_roadmap_engine_task",
"_x_feature_spotlight_task",
"_video_render_task",
"_vault_intake_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()