Files
MemeMeow/backend/application_lifecycle.py
2026-09-29 18:33:10 +08:00

752 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""公共 HTTP 应用的资源生命周期编排。
该模块把 Settings、数据库、scope service factory、后台 Worker、OpenCode、视觉
客户端、callback、workspace 以及关闭顺序放在同一 ownership 边界内。它只依赖
``backend`` canonical 模块和 FastAPI ``app.state``,不得反向导入 ``api``,任务
领域的具体 handler 通过显式回调注入。
"""
from __future__ import annotations
import inspect
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from threading import RLock
from typing import Any, Awaitable, Callable, Mapping, Sequence
from fastapi import FastAPI
from loguru import logger
from sqlalchemy import select
from backend.app_extensions import ApplicationExtension
from backend.callbacks import (
CallbackError,
DEFAULT_CALLBACK_REGISTRY,
HMACCallbackCredentials,
)
from backend.config import Settings, validate_agent_concurrency
from backend.config_http import STORAGE_PREFLIGHT_BLOCKING_KEYS
from backend.database import DatabaseError, DatabaseResources, ScopeContext, check_database, create_engine_for_settings
from backend.image_processing import ACTIVE_JOB_STATUSES, ImageProcessingWorker
from backend.persistence.models import ImageProcessingJob, Task
from backend.opencode import OpenCodeRunner
from backend.opencode_activity import OpenCodeActivityReader
from backend.opencode_workspace import LocalWorkspaceProvider, MissingWorkspaceProvider
from backend.operation_policy import (
AllowAllOperationPolicy,
GrantAssociationStore,
OperationPolicyGateway,
PersistentGrantAssociationStore,
UnavailableOperationPolicy,
)
from backend.pg_services import PostgresMetadataService, PostgresSearchService, PostgresTaskService, PostgresTaskWorkerManager
from backend.paths import PathResolver
from backend.reverse_image import ReverseImageProviderBinding, ReverseImageService
from backend.scope import LocalScopeResolver, ScopeServiceFactory, ScopeServices, validate_scope_services
from backend.visual import VisualInferenceClient, VisualSearchService
@dataclass
class LifecycleSetup:
"""一次 lifespan 已取得的公共资源及其关闭 ownership。
``configured_factory`` 是宿主注入的 factory;``custom_factory`` 用于区分
是否可以在退出时删除本轮默认 factory。其余字段只在对应阶段成功后写入,
便于启动异常时准确收束已创建资源。
"""
app: FastAPI
settings: Settings
local_mode: bool
configured_factory: Any | None
custom_factory: bool
configured_agent_input_provider: Callable[..., Any] | None
engine: Any
shared_worker_executor: ThreadPoolExecutor | None = None
worker_manager: PostgresTaskWorkerManager | None = None
opencode: OpenCodeRunner | None = None
factory: Any | None = None
@dataclass
class ScopeRuntime:
"""scope services、factory 和后台 Worker 的统一 ownership 记录。"""
factory: Any
shared_worker_executor: ThreadPoolExecutor | None
worker_manager: PostgresTaskWorkerManager | None
local_services: ScopeServices | None
tasks: Any | None
@dataclass
class _PreparationOwnership:
"""记录 prepare 阶段已创建的外部资源,供中途失败时回收。"""
engine: Any | None = None
opencode: Any | None = None
@dataclass
class _ScopeBuildOwnership:
"""记录 scope runtime 构造期间已取得的后台资源。"""
executor: ThreadPoolExecutor | None = None
worker_manager: Any | None = None
local_services: ScopeServices | None = None
runtime_factory: Any | None = None
factory_started: bool = False
def _close_resource(resource: Any | None, method_name: str, errors: list[tuple[str, BaseException]], phase: str) -> None:
"""调用一个资源的关闭方法并记录异常,保证同阶段后续资源仍能继续收束。"""
method = getattr(resource, method_name, None)
if not callable(method):
return
try:
method()
except BaseException as exc: # noqa: BLE001 - 关闭阶段必须继续收束其它资源
errors.append((f"{phase}.{method_name}", exc))
def _add_cleanup_notes(primary: BaseException, errors: Sequence[tuple[str, BaseException]]) -> None:
"""把关闭失败的稳定类型和错误码附加到原始异常,不泄露路径或凭据。"""
for phase, error in errors:
code = getattr(error, "code", None) or type(error).__name__
primary.add_note(f"生命周期资源收束失败: {phase} ({code})")
def _clear_lifecycle_state(app: FastAPI, *, managed_factory: bool) -> None:
"""删除本轮已关闭的运行时 state,避免下一轮 lifespan 复用失效资源。"""
_clear_scope_runtime_state(app, managed_factory=managed_factory)
for name in (
"database",
"settings",
"resolver",
"storage_preflight",
"opencode",
"agent_activity",
"visual_inference",
"operation_policy_gateway",
"operation_grants",
):
if hasattr(app.state, name):
delattr(app.state, name)
def _clear_scope_runtime_state(app: FastAPI, *, managed_factory: bool) -> None:
"""只删除 scope runtime 状态,保留 prepare 阶段资源供外层继续关闭。"""
if managed_factory and hasattr(app.state, "service_factory"):
delattr(app.state, "service_factory")
for name in (
"tasks",
"metadata",
"search_engine",
"reverse_image",
"visual_search",
"task_scope_diagnostics",
"image_processing_task_handlers",
"image_processing_workers",
"image_processing_workers_lock",
):
if hasattr(app.state, name):
delattr(app.state, name)
def extension_list(app: FastAPI) -> tuple[ApplicationExtension, ...]:
"""读取应用创建时冻结的扩展列表;缺失时返回空元组。"""
value = getattr(app.state, "extensions", ())
return tuple(value) if isinstance(value, (tuple, list)) else ()
async def invoke_extension_hook(extension: ApplicationExtension, name: str, *args: Any) -> Any:
"""调用可选扩展 hook,并兼容同步和异步实现。"""
hook = getattr(extension, name, None)
if not callable(hook):
return None
result = hook(*args)
if inspect.isawaitable(result):
return await result
return result
def callback_verification_keys(settings: Settings) -> dict[str, str] | None:
"""解析可选的 ``kid=secret`` 轮换验证窗口,格式错误时整体拒绝。"""
raw = getattr(settings, "agent_callback_verification_keys", None)
if raw is None:
return None
if not isinstance(raw, str) or not raw.strip():
raise CallbackError("agent_callback_unavailable")
values: dict[str, str] = {}
for item in raw.split(","):
key_id, separator, secret = item.partition("=")
if not separator or not key_id.strip() or not secret.strip() or key_id.strip() in values:
raise CallbackError("agent_callback_unavailable")
values[key_id.strip()] = secret.strip()
return values
def prepare_lifecycle(
app: FastAPI,
*,
skill_root: Path | None = None,
settings_loader: Callable[[], Settings] | None = None,
engine_factory: Callable[[Any], Any] | None = None,
database_checker: Callable[..., Any] | None = None,
database_resources_factory: Callable[..., DatabaseResources] | None = None,
opencode_factory: Callable[[Settings], OpenCodeRunner] | None = None,
activity_factory: Callable[[Path], Any] | None = None,
visual_factory: Callable[[Settings], Any] | None = None,
) -> LifecycleSetup:
"""按阶段创建公共运行时,并在任何中途失败时释放已取得的资源。"""
ownership = _PreparationOwnership()
try:
return _prepare_lifecycle(
app,
skill_root=skill_root,
settings_loader=settings_loader,
engine_factory=engine_factory,
database_checker=database_checker,
database_resources_factory=database_resources_factory,
opencode_factory=opencode_factory,
activity_factory=activity_factory,
visual_factory=visual_factory,
ownership=ownership,
)
except BaseException as primary:
errors: list[tuple[str, BaseException]] = []
_close_resource(ownership.opencode, "shutdown", errors, "prepare.opencode")
_close_resource(ownership.engine, "dispose", errors, "prepare.engine")
_clear_lifecycle_state(app, managed_factory=bool(getattr(app.state, "_scope_factory_managed", False)))
_add_cleanup_notes(primary, errors)
raise
def _prepare_lifecycle(
app: FastAPI,
*,
skill_root: Path | None = None,
settings_loader: Callable[[], Settings] | None = None,
engine_factory: Callable[[Any], Any] | None = None,
database_checker: Callable[..., Any] | None = None,
database_resources_factory: Callable[..., DatabaseResources] | None = None,
opencode_factory: Callable[[Settings], OpenCodeRunner] | None = None,
activity_factory: Callable[[Path], Any] | None = None,
visual_factory: Callable[[Settings], Any] | None = None,
ownership: _PreparationOwnership,
) -> LifecycleSetup:
"""按 Settings、策略、凭据、数据库和客户端阶段初始化公共资源。
该函数只在 lifespan 内调用;resolver、policy、callback 和 workspace 均从
app state/宿主 factory 读取,不接受客户端字段。返回的 setup 记录负责后续
scope factory/Worker 构造和失败关闭。
"""
managed_factory = bool(getattr(app.state, "_scope_factory_managed", False))
configured_factory = None if managed_factory else getattr(app.state, "service_factory", None)
custom_factory = configured_factory is not None
configured_resolver = getattr(app.state, "scope_resolver", None)
local_mode = isinstance(configured_resolver, LocalScopeResolver) and configured_resolver.scope.scope_id == "local"
settings = (settings_loader or Settings.from_env)()
configured_policy = getattr(app.state, "operation_policy", None)
if configured_policy is None and configured_factory is not None:
configured_policy = getattr(configured_factory, "operation_policy", None)
if configured_policy is None:
configured_policy = AllowAllOperationPolicy() if local_mode else UnavailableOperationPolicy()
if not all(callable(getattr(configured_policy, name, None)) for name in ("probe", "acquire", "commit", "release")):
raise DatabaseError("operation_policy_unavailable")
app.state.operation_policy = configured_policy
app.state.operation_policy_gateway = OperationPolicyGateway(configured_policy, allow_all=isinstance(configured_policy, AllowAllOperationPolicy))
app.state.operation_grants = GrantAssociationStore()
callback_verifier = getattr(app.state, "callback_verifier", None)
callback_issuer = getattr(app.state, "callback_issuer", None)
if callback_verifier is None and configured_factory is not None:
callback_verifier = getattr(configured_factory, "callback_verifier", None)
if callback_issuer is None and configured_factory is not None:
callback_issuer = getattr(configured_factory, "callback_issuer", None)
if local_mode and callback_verifier is None and callback_issuer is None:
try:
# callback 根 secret 必须由部署显式提供;随机凭据会让重启后的验证边界不可预测。
callback_credentials = HMACCallbackCredentials(
getattr(settings, "agent_callback_secret", None),
verification_keys=callback_verification_keys(settings),
)
except CallbackError:
callback_credentials = None
if callback_credentials is not None:
callback_verifier = callback_credentials
callback_issuer = callback_credentials
app.state.callback_verifier = callback_verifier
app.state.callback_issuer = callback_issuer
app.state.callback_registry = getattr(app.state, "callback_registry", DEFAULT_CALLBACK_REGISTRY)
configured_agent_input_provider = getattr(app.state, "agent_input_provider", None)
if configured_agent_input_provider is None and configured_factory is not None:
configured_agent_input_provider = getattr(configured_factory, "agent_input_provider", None)
settings.ensure_directories()
configured_workspace_provider = getattr(app.state, "workspace_provider", None)
if configured_workspace_provider is None and configured_factory is not None:
configured_workspace_provider = getattr(configured_factory, "workspace_provider", None)
configured_model_capability_provider = getattr(app.state, "model_capability_provider", None)
if configured_model_capability_provider is None and configured_factory is not None:
configured_model_capability_provider = getattr(configured_factory, "model_capability_provider", None)
def local_candidate_materializer(context: Any, resolved: Any) -> None:
"""调用当前生命周期的 scope-bound 数据资源物化 local 候选。"""
resources = getattr(app.state, "database", None)
if resources is None:
raise RuntimeError("visual_candidate_materialization_failed")
from backend.visual_candidates import materialize_local_candidates
materialize_local_candidates(resources, context, resolved)
if local_mode:
if configured_workspace_provider is None:
configured_workspace_provider = LocalWorkspaceProvider(
settings.opencode_runtime_root,
image_root=settings.image_root,
skill_root=skill_root or Path(__file__).resolve().parents[1] / "skills" / "research-meme-context",
candidate_materializer=local_candidate_materializer,
)
elif configured_workspace_provider is None:
# non-local 允许完成无业务副作用的装配;真实任务执行时由占位 provider 拒绝。
configured_workspace_provider = MissingWorkspaceProvider()
app.state.workspace_provider = configured_workspace_provider
engine = (engine_factory or create_engine_for_settings)(settings)
ownership.engine = engine
expected_revision = getattr(app.state, "expected_schema_revision", settings.expected_database_revision)
# 启动门禁拒绝任何可能回退到旧 JSON 的业务请求。
(database_checker or check_database)(engine, expected_revision=expected_revision, require_local_installation=local_mode)
app.state.settings = settings
app.state.database = (database_resources_factory or DatabaseResources)(
engine,
image_root=settings.image_root,
data_root=settings.data_root,
settings=settings,
require_local_scope=local_mode,
)
# grant 关联以 PostgreSQL 为跨进程事实来源,内存层只做同进程热点缓存。
app.state.operation_grants = PersistentGrantAssociationStore(app.state.database)
if local_mode:
app.state.resolver = PathResolver(settings.image_root)
preflight = app.state.database.flat_preflight(ScopeContext("local"))
app.state.storage_preflight = preflight
if any(preflight.get(key) for key in STORAGE_PREFLIGHT_BLOCKING_KEYS):
raise DatabaseError("flat_meme_storage_preflight_failed")
else:
# 宿主由自己的 resolver/factory 管理 scope,不探测或创建 local namespace。
app.state.storage_preflight = None
app.state.opencode = (opencode_factory or OpenCodeRunner)(settings)
ownership.opencode = app.state.opencode
app.state.opencode.workspace_provider = configured_workspace_provider
app.state.opencode.model_capability_provider = configured_model_capability_provider
try:
app.state.agent_activity = (activity_factory or OpenCodeActivityReader)(settings.opencode_runtime_root)
except Exception: # noqa: BLE001 - 可选观测配置异常不能阻止任务服务启动
app.state.agent_activity = None
# 视觉模型本体位于独立 CPU 容器,后端只保存推理客户端。
app.state.visual_inference = (visual_factory or VisualInferenceClient)(settings)
return LifecycleSetup(
app=app,
settings=settings,
local_mode=local_mode,
configured_factory=configured_factory,
custom_factory=custom_factory,
configured_agent_input_provider=configured_agent_input_provider,
engine=engine,
opencode=app.state.opencode,
)
def _cleanup_scope_build(ownership: _ScopeBuildOwnership, setup: LifecycleSetup) -> list[tuple[str, BaseException]]:
"""收束 scope runtime 构造失败时已经创建的 Worker、factory 和线程池。"""
errors: list[tuple[str, BaseException]] = []
image_workers = list(getattr(setup.app.state, "image_processing_workers", {}).values())
for image_worker in image_workers:
_close_resource(image_worker, "stop_claiming", errors, "build.image_worker.stop_claiming")
_close_resource(ownership.worker_manager, "stop_claiming", errors, "build.worker_manager.stop_claiming")
_close_resource(setup.opencode, "shutdown", errors, "build.opencode")
for image_worker in reversed(image_workers):
_close_resource(image_worker, "shutdown", errors, "build.image_worker")
if ownership.runtime_factory is not None and (not setup.custom_factory or ownership.factory_started):
_close_resource(ownership.runtime_factory, "shutdown", errors, "build.factory")
_close_resource(ownership.worker_manager, "shutdown", errors, "build.worker_manager")
_close_resource(ownership.executor, "shutdown", errors, "build.executor")
# prepare 阶段的 OpenCode、Database 和 Engine 仍由外层 lifespan 持有,不能
# 在这里提前删除,否则 shutdown_lifecycle 无法再找到并关闭它们。
_clear_scope_runtime_state(setup.app, managed_factory=setup.configured_factory is None)
return errors
def scope_processing_worker(application: FastAPI, scope: ScopeContext) -> ImageProcessingWorker | None:
"""为可信 scope 创建并启动唯一图片 Worker,供请求和启动恢复共同调用。"""
workers = getattr(application.state, "image_processing_workers", None)
if not isinstance(workers, dict):
return None
with application.state.image_processing_workers_lock:
if getattr(application.state, "image_processing_stopping", False):
return None
worker = workers.get(scope.scope_id)
if worker is not None:
return worker
factory = getattr(application.state, "service_factory", None)
if not callable(getattr(factory, "for_scope", None)):
return None
services = validate_scope_services(scope, factory.for_scope(scope))
worker = ImageProcessingWorker(
application.state.database,
scope_id=scope,
task_service=services.tasks,
policy=getattr(application.state, "operation_policy_gateway", None),
grant_store=getattr(application.state, "operation_grants", None),
max_workers=validate_agent_concurrency(application.state.settings.opencode_concurrency),
task_handlers=application.state.image_processing_task_handlers,
)
# 先登记资源,启动失败时生命周期仍能关闭已经创建的线程和执行器。
workers[scope.scope_id] = worker
worker.start()
return worker
def restore_image_processing_workers(application: FastAPI) -> int:
"""发现未完成 Job 和独立图片任务,为其 scope 启动处理 Worker。"""
if getattr(application.state, "image_processing_stopping", False):
return 0
with application.state.database.factory() as session:
scope_ids = set(session.scalars(
select(ImageProcessingJob.scope_id)
.where(ImageProcessingJob.status.in_(ACTIVE_JOB_STATUSES))
.distinct()
.order_by(ImageProcessingJob.scope_id)
))
scope_ids.update(session.scalars(
select(Task.scope_id).where(
Task.status.in_(("queued", "running")),
Task.submission_mode.in_(("pipeline", "standalone")),
Task.task_type.in_(("visual_embedding_generation", "meme_context_generation", "image_auto_rename", "text_embedding_generation")),
).distinct()
))
# 创建服务和开始执行前归还连接,Worker 的扫描及认领使用自己的短事务。
for scope_id in sorted(scope_ids):
if scope_processing_worker(application, ScopeContext(scope_id)) is None:
if getattr(application.state, "image_processing_stopping", False):
return 0
raise DatabaseError("image_processing_worker_unavailable")
return len(scope_ids)
def build_scope_runtime(
setup: LifecycleSetup,
*,
register_handlers: Callable[..., Any],
start_services: Callable[[ScopeServices], Any],
task_handlers: Mapping[str, Callable[..., Any]],
worker_manager_factory: Callable[..., PostgresTaskWorkerManager] | None = None,
metadata_factory: Callable[..., Any] | None = None,
search_factory: Callable[..., Any] | None = None,
task_service_factory: Callable[..., Any] | None = None,
reverse_image_factory: Callable[..., Any] | None = None,
visual_search_factory: Callable[..., Any] | None = None,
) -> ScopeRuntime:
"""创建 scope runtime;失败时继续释放已取得的后台资源并保留原始错误。"""
ownership = _ScopeBuildOwnership()
try:
return _build_scope_runtime(
setup,
register_handlers=register_handlers,
start_services=start_services,
task_handlers=task_handlers,
worker_manager_factory=worker_manager_factory,
metadata_factory=metadata_factory,
search_factory=search_factory,
task_service_factory=task_service_factory,
reverse_image_factory=reverse_image_factory,
visual_search_factory=visual_search_factory,
ownership=ownership,
)
except BaseException as primary:
_add_cleanup_notes(primary, _cleanup_scope_build(ownership, setup))
raise
def _build_scope_runtime(
setup: LifecycleSetup,
*,
register_handlers: Callable[..., Any],
start_services: Callable[[ScopeServices], Any],
task_handlers: Mapping[str, Callable[..., Any]],
worker_manager_factory: Callable[..., PostgresTaskWorkerManager] | None = None,
metadata_factory: Callable[..., Any] | None = None,
search_factory: Callable[..., Any] | None = None,
task_service_factory: Callable[..., Any] | None = None,
reverse_image_factory: Callable[..., Any] | None = None,
visual_search_factory: Callable[..., Any] | None = None,
ownership: _ScopeBuildOwnership,
) -> ScopeRuntime:
"""创建 scope service factory、进程级 Worker 并恢复未完成图片 Job。
``register_handlers`` 和 ``start_services`` 是任务 handler 的显式接线点,避免
canonical 生命周期模块反向依赖 HTTP 入口私有函数。
"""
app = setup.app
settings = setup.settings
provider_binding = getattr(app.state, "reverse_image_provider_binding", None)
if provider_binding is not None and not isinstance(provider_binding, ReverseImageProviderBinding):
raise DatabaseError("reverse_image_provider_binding_invalid")
factory: Any | None = setup.configured_factory
shared_worker_executor: ThreadPoolExecutor | None = None
worker_manager: PostgresTaskWorkerManager | None = None
local_services: ScopeServices | None = None
def prepare_visual_candidates(claim: Any, payload: Mapping[str, object], snapshot: Mapping[str, object]) -> Any:
"""在任务 grant 前委托 Runner/provider 物化当前 claim 的候选视图。"""
runner = setup.opencode
prepare = getattr(runner, "prepare_candidates_for_task", None)
if not callable(prepare):
raise RuntimeError("visual_candidate_materialization_failed")
generation = getattr(claim, "claim_generation", None)
attempt = getattr(claim, "attempt_count", None)
if not isinstance(generation, int) or generation < 1 or not isinstance(attempt, int) or attempt < 1:
raise RuntimeError("visual_candidate_materialization_failed")
return prepare(
task_id=str(claim.id),
attempt_id=f"claim-{generation}-{attempt}",
scope_id=str(claim.scope_id),
selector=payload.get("_workspace_selector") if isinstance(payload.get("_workspace_selector"), str) else None,
session_id=payload.get("_resume_session_id") if isinstance(payload.get("_resume_session_id"), str) else None,
resume_of_attempt_id=payload.get("_resume_of_attempt_id") if isinstance(payload.get("_resume_of_attempt_id"), str) else None,
image_relative_path=payload.get("image_relative_path") if isinstance(payload.get("image_relative_path"), str) else None,
snapshot=snapshot,
)
if factory is not None:
required_methods = ("for_scope", "for_task", "start_all", "shutdown")
if any(not callable(getattr(factory, name, None)) for name in required_methods):
raise DatabaseError("scope_service_factory_invalid")
else:
shared_worker_executor = ThreadPoolExecutor(
max_workers=max(2, settings.opencode_concurrency + 1),
thread_name_prefix="mememeow-scope-worker",
)
ownership.executor = shared_worker_executor
worker_manager = (worker_manager_factory or PostgresTaskWorkerManager)(
app.state.database,
agent_concurrency=settings.opencode_concurrency,
scope_concurrency=getattr(settings, "agent_scope_concurrency", 1),
settings_version=settings.settings_version,
lease_seconds=settings.worker_lease_seconds,
max_attempts=settings.worker_max_attempts,
executor=shared_worker_executor,
)
ownership.worker_manager = worker_manager
if setup.local_mode:
local_scope = ScopeContext("local")
local_metadata = (metadata_factory or PostgresMetadataService)(app.state.database, scope_id=local_scope)
local_search = (search_factory or PostgresSearchService)(settings, app.state.database, local_metadata, scope_id=local_scope)
reverse_image_kwargs: dict[str, Any] = {
"scope_id": local_scope,
"operation_policy": app.state.operation_policy_gateway,
"grant_store": app.state.operation_grants,
}
if provider_binding is not None:
reverse_image_kwargs["provider_binding"] = provider_binding
try:
local_reverse_image = (reverse_image_factory or ReverseImageService)(
settings,
app.state.database,
**reverse_image_kwargs,
)
except TypeError as exc:
if "operation_policy" not in str(exc) and "grant_store" not in str(exc):
raise
# 兼容尚未升级的轻量 facade 夹具;真实服务支持 policy 参数。
fallback_kwargs: dict[str, Any] = {"scope_id": local_scope}
if provider_binding is not None:
fallback_kwargs["provider_binding"] = provider_binding
local_reverse_image = (reverse_image_factory or ReverseImageService)(settings, app.state.database, **fallback_kwargs)
local_visual_search = (visual_search_factory or VisualSearchService)(settings, app.state.database, scope_id=local_scope)
local_tasks = (task_service_factory or PostgresTaskService)(
app.state.database,
scope_id=local_scope,
agent_concurrency=settings.opencode_concurrency,
scope_concurrency=getattr(settings, "agent_scope_concurrency", 1),
settings_version=settings.settings_version,
lease_seconds=settings.worker_lease_seconds,
max_attempts=settings.worker_max_attempts,
resume_enabled=bool(getattr(settings, "agent_resume_enabled", False)),
resume_max_attempts=int(getattr(settings, "agent_resume_max_attempts", 2)),
resume_backoff_seconds=int(getattr(settings, "agent_resume_backoff_seconds", 2)),
resume_max_backoff_seconds=int(getattr(settings, "agent_resume_max_backoff_seconds", 60)),
resume_timeout_seconds=int(getattr(settings, "agent_resume_timeout_seconds", 900)),
visual_snapshot_preparer=getattr(local_visual_search, "precompute_snapshot", None),
visual_candidate_preparer=prepare_visual_candidates,
worker_manager=worker_manager,
operation_policy=app.state.operation_policy_gateway,
grant_store=app.state.operation_grants,
)
from backend.services.thumbnails import DerivedThumbnailService
thumbnail_store = getattr(app.state.database, "thumbnail_store_for_scope", None)
local_thumbnails = (
DerivedThumbnailService(
app.state.database,
settings,
scope_id=local_scope,
task_service=local_tasks,
)
if callable(thumbnail_store)
else None
)
local_services = ScopeServices(
local_scope,
local_metadata,
local_search,
local_tasks,
local_reverse_image,
local_visual_search,
local_thumbnails,
)
ownership.local_services = local_services
runtime_factory: Any
if factory is None:
# 回调本身只由公共 scope factory 调用,不保存 HTTP 入口模块引用。
def start_scope_services(services: ScopeServices) -> Any:
return start_services(services)
runtime_factory = ScopeServiceFactory(
app.state.database,
settings,
task_config={
"agent_concurrency": settings.opencode_concurrency,
"scope_concurrency": getattr(settings, "agent_scope_concurrency", 1),
"settings_version": settings.settings_version,
"lease_seconds": settings.worker_lease_seconds,
"max_attempts": settings.worker_max_attempts,
"resume_enabled": bool(getattr(settings, "agent_resume_enabled", False)),
"resume_max_attempts": int(getattr(settings, "agent_resume_max_attempts", 2)),
"resume_backoff_seconds": int(getattr(settings, "agent_resume_backoff_seconds", 2)),
"resume_max_backoff_seconds": int(getattr(settings, "agent_resume_max_backoff_seconds", 60)),
"resume_timeout_seconds": int(getattr(settings, "agent_resume_timeout_seconds", 900)),
"executor": shared_worker_executor,
"operation_policy": app.state.operation_policy_gateway,
"grant_store": app.state.operation_grants,
"reverse_provider_binding": provider_binding,
"visual_candidate_preparer": prepare_visual_candidates,
"register_handlers": register_handlers,
"start_services": start_scope_services,
},
preloaded={"local": local_services} if local_services is not None else None,
worker_manager=worker_manager,
)
else:
runtime_factory = factory
ownership.runtime_factory = runtime_factory
if worker_manager is not None:
# 非 local 默认 factory 不预加载 scope;先注册全局 handler 再恢复队列。
register_handlers(manager=worker_manager)
if local_services is not None:
app.state.metadata = local_services.metadata
app.state.search_engine = local_services.search
app.state.reverse_image = local_services.reverse_image
app.state.visual_search = local_services.visual_search
register_handlers(local_services)
local_services.metadata.recover_storage(limit=500)
app.state.service_factory = runtime_factory
app.state._scope_factory_managed = setup.configured_factory is None
app.state.agent_input_provider = setup.configured_agent_input_provider
app.state.image_processing_task_handlers = dict(task_handlers)
app.state.image_processing_workers = {}
app.state.image_processing_workers_lock = RLock()
app.state.image_processing_stopping = False
tasks = local_services.tasks if local_services is not None else None
if tasks is not None:
app.state.tasks = tasks
ownership.factory_started = True
app.state.task_scope_diagnostics = runtime_factory.start_all()
if local_services is not None:
scope_processing_worker(app, local_services.scope)
restore_image_processing_workers(app)
if worker_manager is not None:
# 独立命名进程可以在 API 启动之后为任意 scope 提交文本任务。
worker_manager.register_maintenance(lambda: restore_image_processing_workers(app))
setup.factory = runtime_factory
setup.worker_manager = worker_manager
setup.shared_worker_executor = shared_worker_executor
return ScopeRuntime(runtime_factory, shared_worker_executor, worker_manager, local_services, tasks)
async def start_extensions(app: FastAPI, started: list[ApplicationExtension] | None = None) -> list[ApplicationExtension]:
"""在公共数据库、factory 和 Worker 就绪后按顺序启动扩展。
``started`` 由调用方提供时会原地记录当前启动项,连同正在失败的扩展一起
保留,确保外层 lifespan 在部分启动失败时仍能执行完整 shutdown hook。
"""
started = started if started is not None else []
for extension in extension_list(app):
# hook 可能在中途创建后台任务后才抛错,因此失败项也必须进入 cleanup 集合。
started.append(extension)
await invoke_extension_hook(extension, "on_startup", app)
return started
async def shutdown_lifecycle(
setup: LifecycleSetup | None,
runtime: ScopeRuntime | None,
started_extensions: Sequence[ApplicationExtension] = (),
*,
primary_error: BaseException | None = None,
) -> None:
"""按依赖顺序关闭资源,并在单步失败时继续收束后续资源。"""
if setup is None:
return
app = setup.app
errors: list[tuple[str, BaseException]] = []
for extension in reversed(tuple(started_extensions)):
try:
await invoke_extension_hook(extension, "on_shutdown", app)
except BaseException as exc: # noqa: BLE001 - 扩展失败不能跳过核心资源关闭
errors.append(("shutdown.extension", exc))
opencode = getattr(app.state, "opencode", None)
worker_lock = getattr(app.state, "image_processing_workers_lock", None)
if worker_lock is not None:
with worker_lock:
app.state.image_processing_stopping = True
image_workers = list(getattr(app.state, "image_processing_workers", {}).values())
else:
image_workers = []
for image_worker in image_workers:
_close_resource(image_worker, "stop_claiming", errors, "shutdown.image_worker.stop_claiming")
if runtime is not None:
_close_resource(runtime.worker_manager, "stop_claiming", errors, "shutdown.worker_manager.stop_claiming")
_close_resource(opencode, "shutdown", errors, "shutdown.opencode")
for image_worker in reversed(image_workers):
_close_resource(image_worker, "shutdown", errors, "shutdown.image_worker")
if runtime is not None:
if runtime.shared_worker_executor is not None:
try:
runtime.shared_worker_executor.shutdown(wait=True, cancel_futures=True)
except BaseException as exc: # noqa: BLE001 - 线程池失败不能阻止 Engine 收束
errors.append(("shutdown.executor", exc))
_close_resource(runtime.worker_manager, "shutdown", errors, "shutdown.worker_manager")
_close_resource(runtime.factory, "shutdown", errors, "shutdown.factory")
_close_resource(setup.engine, "dispose", errors, "shutdown.engine")
_clear_lifecycle_state(app, managed_factory=not setup.custom_factory)
if primary_error is not None:
_add_cleanup_notes(primary_error, errors)
return
if errors:
_add_cleanup_notes(errors[0][1], errors[1:])
raise errors[0][1]
__all__ = [
"LifecycleSetup",
"ScopeRuntime",
"build_scope_runtime",
"callback_verification_keys",
"extension_list",
"invoke_extension_hook",
"prepare_lifecycle",
"shutdown_lifecycle",
"start_extensions",
]