"""验收后伏笔到期扫描(M3-b/d;ARCH §6.2, §7.4,不变量 #3)。 接 §5.5 验收事务后的 `TODO(M3)` 占位:验收**提交成功后**触发一次伏笔到期扫描,把 `current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 `OVERDUE`(M3-d 纯 函数,非 Agent;不变量 #3:状态变更是确定性扫描而非 AI 静默写库)。 挂载方式(端点):FastAPI `BackgroundTasks` 在 accept 端点登记 `run_overdue_scan`。 BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故本函数**自建新 session**(不复用请求 session)并自己 `commit()`。 **持久性局限(§7.4)**:BackgroundTasks 在 API 进程内跑,进程重启/崩溃会丢失未跑完的 任务——原型可接受,这里把触发/完成/失败都记结构化日志便于查错;**不引入 `jobs` 表** (那是 M4/T4.1 的工作,本任务不越界)。 可测性:扫描逻辑抽成纯 async 函数 `run_overdue_scan(session_factory, ...)`,端点用 BackgroundTasks 调它;单测直接 `await` 它(注入 fake session 工厂)断言行被置 OVERDUE, **绝不真起不可控后台线程**。 """ from __future__ import annotations import uuid from collections.abc import Callable from contextlib import AbstractAsyncContextManager from typing import Protocol import structlog from sqlalchemy.ext.asyncio import AsyncSession from ww_core.domain import ForeshadowLedgerView, SqlForeshadowLedgerRepo log = structlog.get_logger(__name__) # 新建独立 session 的工厂:调用得到一个 async 上下文管理器(`async with` → AsyncSession)。 SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] class OverdueScanRepo(Protocol): """到期扫描对账本 repo 的最小依赖(仅 `scan_overdue`)。""" async def scan_overdue( self, project_id: uuid.UUID, *, current_chapter: int ) -> list[ForeshadowLedgerView]: ... # repo 工厂:从新 session 造账本 repo。默认建 SQL 实现;测试注入 fake(避免真连 DB)。 RepoFactory = Callable[[AsyncSession], OverdueScanRepo] def _default_repo_factory(session: AsyncSession) -> OverdueScanRepo: return SqlForeshadowLedgerRepo(session) async def run_overdue_scan( session_factory: SessionFactory, *, project_id: uuid.UUID, chapter_no: int, request_id: str | None = None, repo_factory: RepoFactory = _default_repo_factory, ) -> int: """验收后伏笔到期扫描:新建 session → `scan_overdue` 置 OVERDUE → `commit()`。 返回被置 OVERDUE 的伏笔条数(供日志/测试断言)。任何异常被捕获并记错误日志后 吞掉——后台任务失败不应冒泡崩进程,也不影响已成功的验收事务(§7.4 局限可接受)。 `repo_factory` 是可注入缝:默认建 SQL 账本 repo,测试注 fake(直接 await、不联网)。 """ try: async with session_factory() as session: repo = repo_factory(session) changed = await repo.scan_overdue(project_id, current_chapter=chapter_no) if changed: await session.commit() log.info( "foreshadow_overdue_scan", project_id=str(project_id), chapter_no=chapter_no, request_id=request_id, overdue_count=len(changed), overdue_codes=[v.code for v in changed], ) return len(changed) except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误、不冒泡崩进程(§7.4)。 log.error( "foreshadow_overdue_scan_failed", project_id=str(project_id), chapter_no=chapter_no, request_id=request_id, error=str(exc), ) return 0