[F117] stop the orchestrator in lifespan shutdown BEFORE closing the DB

The lifespan shutdown closed OptimalService + the DB, and only THEN did
bootstrap's finally block call orchestrator.stop() — so stop() ran with
the DB already closed. stop() drains fire-and-forget _bg_tasks writes
(respawn_tracker upserts, audit-log rows) and stop_agent finalizes work
sessions / agent state, all needing the DB still open; closing it first
silently dropped those final writes (the durable PM-respawn counter's
last few strikes, the metrics-bearing audit trail tail).

Move orchestrator.stop() into the lifespan shutdown path, BEFORE
close_optimal_service + close_db, guarded by a new get_orchestrator_or_none()
safe accessor (no crash when no orchestrator is wired — tests,
skip_orchestrator). bootstrap's finally-block stop() becomes an idempotent
safety net: stop() gains a _stopped flag (getattr-guarded so __new__-
constructed test instances still stop) so the double-call is a clean no-op,
not a re-stop of already-stopped agents / re-drain of an empty bg set.
This commit is contained in:
Renn F
2026-06-28 23:10:47 +02:00
parent 1a773e45d7
commit fc01bee39c
5 changed files with 131 additions and 1 deletions
+19 -1
View File
@@ -11,7 +11,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from roboco.api.deps import _auth_required from roboco.api.deps import _auth_required, get_orchestrator_or_none
from roboco.api.middleware import setup_middleware from roboco.api.middleware import setup_middleware
from roboco.api.routes.a2a import router as a2a_router from roboco.api.routes.a2a import router as a2a_router
from roboco.api.routes.a2a import wellknown_router as a2a_wellknown_router from roboco.api.routes.a2a import wellknown_router as a2a_wellknown_router
@@ -167,6 +167,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
if _AppServices.transcription: if _AppServices.transcription:
await _AppServices.transcription.stop() await _AppServices.transcription.stop()
# Stop the orchestrator BEFORE closing the DB / OptimalService. stop()
# cancels the background loops, stops the agents (finalizing work sessions
# + agent state via DB writes), and drains fire-and-forget bg writes
# (respawn_tracker upserts, audit-log rows) — all needing the DB still
# open. Closing the DB first silently dropped those final writes (the old
# order, where only bootstrap's finally block called stop() AFTER lifespan
# had already closed the DB). Best-effort: a stop error must not block the
# resource teardown below. No-op when no orchestrator is wired (tests,
# skip_orchestrator). bootstrap's finally block re-calls stop() as a safety
# net; stop() is idempotent (guarded by the _stopped flag) so the second
# call is a no-op.
orchestrator = get_orchestrator_or_none()
if orchestrator is not None:
try:
await orchestrator.stop()
except Exception as e:
logger.warning("Orchestrator stop failed during shutdown", error=str(e))
# Close Phase 3 services # Close Phase 3 services
await close_optimal_service() await close_optimal_service()
+16
View File
@@ -93,6 +93,11 @@ def set_orchestrator(orchestrator: AgentOrchestrator) -> None:
_ServiceHolder.orchestrator = orchestrator _ServiceHolder.orchestrator = orchestrator
def clear_orchestrator() -> None:
"""Clear the global orchestrator instance (test teardown / re-init)."""
_ServiceHolder.orchestrator = None
def get_orchestrator() -> AgentOrchestrator: def get_orchestrator() -> AgentOrchestrator:
"""Get the global orchestrator instance.""" """Get the global orchestrator instance."""
if _ServiceHolder.orchestrator is None: if _ServiceHolder.orchestrator is None:
@@ -103,6 +108,17 @@ def get_orchestrator() -> AgentOrchestrator:
return _ServiceHolder.orchestrator return _ServiceHolder.orchestrator
def get_orchestrator_or_none() -> AgentOrchestrator | None:
"""The global orchestrator instance, or ``None`` if not set.
Used by the lifespan shutdown path, which must stop the orchestrator
BEFORE closing the DB (orchestrator.stop() drains fire-and-forget DB
writes and finalizes agent state) but must not crash when the app is run
without a bootstrap-set orchestrator (e.g. tests, ``skip_orchestrator``).
"""
return _ServiceHolder.orchestrator
OrchestratorDep = Annotated[AgentOrchestrator, Depends(get_orchestrator)] OrchestratorDep = Annotated[AgentOrchestrator, Depends(get_orchestrator)]
+13
View File
@@ -844,6 +844,10 @@ class AgentOrchestrator:
# instead of waiting for the next 30-second tick. # instead of waiting for the next 30-second tick.
self._dispatch_wake: asyncio.Event = asyncio.Event() self._dispatch_wake: asyncio.Event = asyncio.Event()
self._running = False self._running = False
# Set True once stop() completes — makes the (lifespan + bootstrap
# safety-net) double-call a clean no-op instead of re-stopping already
# stopped agents / re-draining an empty bg-task set.
self._stopped = False
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# Serializes CEO supersede calls so a double-click can't pass the # Serializes CEO supersede calls so a double-click can't pass the
# find_supersede_umbrella dedup check twice and cut two branches / # find_supersede_umbrella dedup check twice and cut two branches /
@@ -1023,6 +1027,14 @@ class AgentOrchestrator:
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the orchestrator and all agents.""" """Stop the orchestrator and all agents."""
if getattr(self, "_stopped", False):
# Idempotent: the lifespan shutdown path stops the orchestrator
# before closing the DB, and bootstrap's finally block re-calls
# stop() as a safety net. The second call must be a no-op, not a
# re-stop of already-stopped agents. ``getattr`` so a ``__new__``-
# constructed instance (unit-test pattern) without ``__init__`` is
# still stoppable.
return
self._running = False self._running = False
# Cancel every background loop, then stop the agents. # Cancel every background loop, then stop the agents.
@@ -1057,6 +1069,7 @@ class AgentOrchestrator:
# stuck task can't hang shutdown — it is cancelled past the deadline. # stuck task can't hang shutdown — it is cancelled past the deadline.
await self._drain_bg_tasks() await self._drain_bg_tasks()
self._stopped = True
logger.info("Orchestrator stopped") logger.info("Orchestrator stopped")
async def _ensure_agent_image(self, agent_id: str | None = None) -> None: async def _ensure_agent_image(self, agent_id: str | None = None) -> None:
+57
View File
@@ -16,6 +16,7 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from roboco.api.app import app as default_app from roboco.api.app import app as default_app
from roboco.api.app import create_app, lifespan from roboco.api.app import create_app, lifespan
from roboco.api.deps import clear_orchestrator, set_orchestrator
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -144,6 +145,62 @@ async def test_lifespan_startup_and_shutdown_happy_path() -> None:
transcription_mock.stop.assert_awaited_once() transcription_mock.stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_lifespan_stops_orchestrator_before_closing_db_and_optimal() -> None:
"""F117: orchestrator.stop() must run BEFORE close_optimal_service / close_db
on shutdown. stop() drains fire-and-forget DB writes (respawn_tracker
upserts, audit-log rows) and stop_agent finalizes work sessions / agent
state — all needing the DB still open. Closing the DB first (the old order,
where only bootstrap's finally called stop() after lifespan had already
closed the DB) silently dropped those final writes."""
order: list[str] = []
def _record(label: str) -> AsyncMock:
async def _fn() -> None:
order.append(label)
return AsyncMock(side_effect=_fn)
transcription_mock = MagicMock()
transcription_mock.start = AsyncMock()
transcription_mock.stop = AsyncMock()
orchestrator_mock = MagicMock()
orchestrator_mock.stop = _record("orchestrator.stop")
set_orchestrator(orchestrator_mock)
try:
with (
patch("roboco.api.app.init_db", new=AsyncMock()),
patch("roboco.api.app.close_db", new=_record("close_db")),
patch(
"roboco.api.app.close_optimal_service",
new=_record("close_optimal_service"),
),
patch(
"roboco.api.app.TranscriptionService", return_value=transcription_mock
),
patch("roboco.api.app.ExtractionService"),
patch("roboco.api.app.ExtractionPipeline"),
patch(
"roboco.api.app.get_optimal_service",
new=AsyncMock(return_value=MagicMock()),
),
):
app = create_app()
async with lifespan(app):
pass
finally:
# Clear the global so it doesn't leak into other tests.
clear_orchestrator()
# orchestrator.stop() ran, and it ran BEFORE close_optimal_service + close_db.
assert "orchestrator.stop" in order
assert order.index("orchestrator.stop") < order.index("close_optimal_service")
assert order.index("orchestrator.stop") < order.index("close_db")
# The DB is still the last thing closed (innermost resource).
assert order.index("close_optimal_service") < order.index("close_db")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lifespan_handles_optimal_init_failure_gracefully() -> None: async def test_lifespan_handles_optimal_init_failure_gracefully() -> None:
"""Optimal-service init failure → app.state.optimal=None, no raise.""" """Optimal-service init failure → app.state.optimal=None, no raise."""
@@ -148,3 +148,29 @@ async def test_stop_failing_agent_does_not_skip_drain() -> None:
await asyncio.wait_for(orch.stop(), timeout=5.0) await asyncio.wait_for(orch.stop(), timeout=5.0)
assert ran == [True], "failing stop_agent skipped the drain (data lost)" 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:
"""F117: stop() is idempotent. The lifespan shutdown path now stops the
orchestrator before closing the DB, and bootstrap's finally block re-calls
stop() as a safety net. The second call must be a clean no-op — not a
re-drain, not a 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()
orch._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