Files
writer-work-flow/apps/api/ww_api/services/job_runner.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

98 lines
4.3 KiB
Python
Raw 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.

"""通用长任务 runnerBackgroundTask 跑 `jobs` 表上的异步工作ARCH §7.4)。
T4.3「学文风走 jobs」复用此基建`POST /style` 立即写一行 `jobs(status=queued)` 返
202 `{job_id}`,再经 FastAPI `BackgroundTasks` 登记 `run_job(...)` 跑真正的提取工作。
**独立 session 纪律**(同 `services/foreshadow_scan.run_overdue_scan` 先例 + memory/gotchas
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故 `run_job`
**自建新 session**(经 `session_factory`),绝不复用请求 session。
`work: Callable[[AsyncSession], Awaitable[dict]]` 是业务逻辑缝T4.3 部分应用「跑提取
→ 写 style_fingerprint → 返回 result 摘要」)。`work` 拿到的 session 与 job 状态写同一
session → 一次 `commit()` 一并落库(业务写 + job done 原子)。
可测性:`run_job` 经可注入 `session_factory`/`repo_factory` 缝——单测直接 `await` 它,
注 fake session 工厂 + fake job repo + fake work**不起后台线程、不连真 DB**),断言
成功路置 done、异常路置 failed。
"""
from __future__ import annotations
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.job_repo import JobView, SqlJobRepo
from ww_api.services.foreshadow_scan import SessionFactory
log = structlog.get_logger(__name__)
# 业务工作缝:拿 session 跑真正的长任务,返回写回 job.result 的摘要 dict。
JobWork = Callable[[AsyncSession], Awaitable[dict[str, Any]]]
class JobLifecycleRepo(Protocol):
"""`run_job` 对 job repo 的**最小**依赖(仅生命周期三态写)——便于注入 fake。"""
async def set_running(self, job_id: uuid.UUID) -> JobView: ...
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: ...
async def fail(self, job_id: uuid.UUID, error: str) -> JobView: ...
# repo 工厂:从新 session 造 job repo。默认建 SQL 实现;测试注 fake避免真连 DB
JobRepoFactory = Callable[[AsyncSession], JobLifecycleRepo]
def _default_repo_factory(session: AsyncSession) -> JobLifecycleRepo:
return SqlJobRepo(session)
async def run_job(
session_factory: SessionFactory,
job_id: uuid.UUID,
work: JobWork,
*,
request_id: str | None = None,
repo_factory: JobRepoFactory = _default_repo_factory,
) -> None:
"""跑一个长任务:新建独立 session → set_running → await work → complete/fail → commit。
成功:`complete(job_id, result)`status=done, progress=100, result=work 返回值)后 commit。
异常:回滚 work 的部分写 → 新 session 里 `fail(job_id, str(exc))` → commitjob 失败可见)。
任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。
`session_factory`/`repo_factory` 是可注入缝:测试直接 await、注 fake绝不联网/起线程。
"""
try:
async with session_factory() as session:
repo = repo_factory(session)
await repo.set_running(job_id)
result = await work(session)
await repo.complete(job_id, result)
await session.commit()
log.info("job_done", job_id=str(job_id), request_id=request_id)
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed不冒泡。
log.error("job_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
await _mark_failed(session_factory, job_id, str(exc), repo_factory, request_id)
async def _mark_failed(
session_factory: SessionFactory,
job_id: uuid.UUID,
error: str,
repo_factory: JobRepoFactory,
request_id: str | None,
) -> None:
"""在一个**全新** session 里把 job 置 failed前一 session 的事务已因异常作废)。"""
try:
async with session_factory() as session:
repo = repo_factory(session)
await repo.fail(job_id, error)
await session.commit()
except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。
log.error("job_fail_mark_failed", job_id=str(job_id), request_id=request_id, error=str(exc))