fix(chain): 修评审 CRITICAL+HIGH — 链网关按 session 重建/日志脱敏/resume 原子化+所有权/死导入

- CRITICAL #1:链 write/review 节点经 gateway_builder 按节点自建 session 现建网关,
  usage_ledger sink 绑活 session,随节点 commit 持久化;run_chain_job 不再转发请求网关
  (其 session 在 BackgroundTask 跑时已关闭,记账行被静默丢弃)。新增 get_chain_gateway_builder
  缝(仿 digest builder),get_chain_gateway 退化为纯 503 凭据预检。守不变量 #1。
- HIGH #2:chain_runner 失败日志不再记 str(exc)(可能含 key/连接串/LLM 输出),改记
  _classify_job_error 脱敏文案 + exc_type(设计 §5)。
- HIGH #3:resume 端点原子抢占 awaiting→running(JobRepo.claim_awaiting_to_running 条件
  UPDATE),抢不到 → 409,防并发 resume 双 Command(resume) 损坏图。
- HIGH #4:resume 校验 job.project_id == project_id(JobView 新增 project_id),不匹配 → 404。
- HIGH #5:resume 返回新 ChainResumeAccepted{job_id,chain_key},去掉无意义哨兵 start/count=0。
- HIGH #6:删 nodes.py 死导入 extract_conflicts(import + __all__)。
- 测试 #7:e2e 断言链跑后 usage_ledger 有行(#1 回归守卫)+ chapter_reviews 每章一行;
  新增 claim 原子抢占单测 + resume 跨项目 404 / 并发 409 端点测。
This commit is contained in:
Yaojia Wang
2026-06-23 18:04:10 +02:00
parent 061792db1c
commit d1ea83b191
13 changed files with 316 additions and 37 deletions

View File

@@ -414,7 +414,13 @@ class FakeJobRepo:
self.rows: dict[uuid.UUID, JobView] = {}
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0)
view = JobView(
id=uuid.uuid4(),
project_id=project_id,
kind=kind,
status=STATUS_QUEUED,
progress=0,
)
self.rows[view.id] = view
return view
@@ -442,6 +448,15 @@ class FakeJobRepo:
self.rows[job_id] = view
return view
async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
"""原子抢占 awaiting→running内存替身仅当现态 awaiting 才抢到,否则 None"""
current = self.rows.get(job_id)
if current is None or current.status != STATUS_AWAITING:
return None
view = current.model_copy(update={"status": STATUS_RUNNING})
self.rows[job_id] = view
return view
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
self.rows[job_id] = view

View File

@@ -151,6 +151,19 @@ async def _memsaver_ctx(saver: MemorySaver) -> AsyncIterator[Any]:
yield saver
def _async_returning(gateway: Any) -> Any:
"""构造「按 session 建网关」的 builder 替身:忽略 session恒返给定 mock 网关(绝不联网)。
链节点经 `gateway_builder(session)` 现建网关(不变量 #1测试里 ledger 不落库fake
session故 builder 只需把 mock 网关交回即可。
"""
async def _build(_session: Any) -> Any:
return gateway
return _build
class _FakeContext:
stable_core = "## 世界观硬规则\n灵气可凝丹"
volatile = "## 写作指令\n写第 N 章"
@@ -169,6 +182,7 @@ def _app(project_repo: FakeProjectRepo, job_repo: FakeJobRepo) -> FastAPI:
from ww_api.services.chain_deps import get_checkpointer_factory
from ww_api.services.project_deps import (
get_chain_gateway,
get_chain_gateway_builder,
get_digest_gateway_builder,
get_job_repo,
get_project_repo,
@@ -182,6 +196,9 @@ def _app(project_repo: FakeProjectRepo, job_repo: FakeJobRepo) -> FastAPI:
app.dependency_overrides[get_session] = lambda: _FakeSession()
app.dependency_overrides[get_session_factory] = lambda: _session_factory
app.dependency_overrides[get_chain_gateway] = lambda: _FakeChainGateway(conflicts=[])
app.dependency_overrides[get_chain_gateway_builder] = lambda: _async_returning(
_FakeChainGateway(conflicts=[])
)
saver = MemorySaver()
app.dependency_overrides[get_checkpointer_factory] = lambda: lambda: _memsaver_ctx(saver)
app.dependency_overrides[get_digest_gateway_builder] = lambda: lambda _s: None
@@ -363,6 +380,56 @@ async def test_resume_chain_job_not_found_returns_404(
assert resp.status_code == 404
async def test_resume_chain_wrong_project_returns_404(
_noop_run_chain_job: list[dict[str, Any]],
) -> None:
"""所有权校验(审评 #4job 属项目 A从项目 B 路径 resume → 404同案不枚举"""
project_repo = FakeProjectRepo()
job_repo = FakeJobRepo()
pid_a = await _seed_project(project_repo)
pid_b = uuid.UUID(
str((await project_repo.create(PROJECT_OWNER, ProjectCreate(title="作品B"))).id)
)
job = await job_repo.create(pid_a, "chain") # job 属 A
await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1})
app = _app(project_repo, job_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid_b}/chains/runs/{job.id}/resume", # 从 B 续 A 的 job
json={"decisions": []},
)
assert resp.status_code == 404
assert len(_noop_run_chain_job) == 0 # 未调度
async def test_resume_chain_concurrent_claim_returns_409(
_noop_run_chain_job: list[dict[str, Any]],
) -> None:
"""原子抢占(审评 #3第一个 resume 已把 awaiting→running第二个抢不到 → 409。"""
project_repo = FakeProjectRepo()
job_repo = FakeJobRepo()
pid = await _seed_project(project_repo)
job = await job_repo.create(pid, "chain")
await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1})
# 模拟并发:第一个 resume 已抢占 awaiting→running。
claimed = await job_repo.claim_awaiting_to_running(job.id)
assert claimed is not None
app = _app(project_repo, job_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chains/runs/{job.id}/resume",
json={"decisions": []},
)
# job 现态 running非 awaiting→ 守卫先在 status 检查处 409CONFLICT
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "CONFLICT"
assert len(_noop_run_chain_job) == 0
# ---- 服务层 run_chain_job直接 await注入全 fakejob 状态由 fake SqlJobRepo 替身记录)----
@@ -440,7 +507,7 @@ async def test_run_chain_job_no_conflict_completes_done(_patched_runner: None) -
chain_key="draft_volume",
start_chapter_no=1,
count=2,
gateway=gateway,
chain_gateway_builder=_async_returning(gateway),
checkpointer_ctx=lambda: _memsaver_ctx(saver),
accept_op=fakes["accept_op"],
chapter_repo_factory=fakes["chapter_repo_factory"],
@@ -476,7 +543,7 @@ async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
chain_key="draft_volume",
start_chapter_no=1,
count=1,
gateway=gateway,
chain_gateway_builder=_async_returning(gateway),
checkpointer_ctx=lambda: _memsaver_ctx(saver),
accept_op=fakes["accept_op"],
chapter_repo_factory=fakes["chapter_repo_factory"],
@@ -498,7 +565,7 @@ async def test_run_chain_job_conflict_sets_awaiting_then_resume_done(
chain_key="draft_volume",
start_chapter_no=1,
count=1,
gateway=gateway,
chain_gateway_builder=_async_returning(gateway),
checkpointer_ctx=lambda: _memsaver_ctx(saver),
accept_op=fakes["accept_op"],
chapter_repo_factory=fakes["chapter_repo_factory"],
@@ -532,7 +599,7 @@ async def test_run_chain_job_error_marks_failed_safely(_patched_runner: None) ->
chain_key="draft_volume",
start_chapter_no=1,
count=1,
gateway=_BoomGateway(),
chain_gateway_builder=_async_returning(_BoomGateway()),
checkpointer_ctx=lambda: _memsaver_ctx(MemorySaver()),
accept_op=fakes["accept_op"],
chapter_repo_factory=fakes["chapter_repo_factory"],

View File

@@ -28,6 +28,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_api.logging_config import get_logger
from ww_api.schemas.chain import (
ChainResumeAccepted,
ChainResumeRequest,
ChainRunAccepted,
ChainRunRequest,
@@ -44,8 +45,10 @@ from ww_api.services.chain_runner import (
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.project_deps import (
GatewayChainBuilder,
GatewayDigestBuilder,
get_chain_gateway,
get_chain_gateway_builder,
get_digest_gateway_builder,
get_job_repo,
get_project_repo,
@@ -66,6 +69,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
CheckpointerFactoryDep = Annotated[CheckpointerFactory, Depends(get_checkpointer_factory)]
DigestBuilderDep = Annotated[GatewayDigestBuilder, Depends(get_digest_gateway_builder)]
ChainBuilderDep = Annotated[GatewayChainBuilder, Depends(get_chain_gateway_builder)]
_RUN_ERRORS: dict[int | str, dict[str, Any]] = {
404: {"model": ErrorEnvelope, "description": "项目或链 key 不存在"},
@@ -97,11 +101,12 @@ async def run_chain(
background_tasks: BackgroundTasks,
project_repo: ProjectRepoDep,
job_repo: JobRepoDep,
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503;不用于实际 LLM 调用
session: SessionDep,
session_factory: SessionFactoryDep,
checkpointer_factory: CheckpointerFactoryDep,
digest_gateway_builder: DigestBuilderDep,
chain_gateway_builder: ChainBuilderDep,
) -> ChainRunAccepted:
"""发起多章链:写一行 job 返 202链经 BackgroundTask 异步跑。
@@ -132,7 +137,7 @@ async def run_chain(
chain_key=chain_key,
start_chapter_no=body.start_chapter_no,
count=body.count,
gateway=gateway,
chain_gateway_builder=chain_gateway_builder,
checkpointer_ctx=checkpointer_factory,
accept_op=accept_op,
chapter_repo_factory=_chapter_repo_factory,
@@ -172,22 +177,26 @@ async def resume_chain(
background_tasks: BackgroundTasks,
project_repo: ProjectRepoDep,
job_repo: JobRepoDep,
gateway: ChainGatewayDep,
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503不用于实际 LLM 调用
session: SessionDep,
session_factory: SessionFactoryDep,
checkpointer_factory: CheckpointerFactoryDep,
digest_gateway_builder: DigestBuilderDep,
) -> ChainRunAccepted:
chain_gateway_builder: ChainBuilderDep,
) -> ChainResumeAccepted:
"""裁决续跑:仅当 job=awaiting_input带裁决经 BackgroundTask resume。
项目不存在 → 404job 不存在 → 404非 awaiting 态 → 409CONFLICT
resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id=job_id
项目不存在 → 404job 不存在 / 不属于该项目 → 404同案不枚举非 awaiting 态 →
409CONFLICT。`awaiting→running` 在 HTTP 处理器内**原子抢占**(条件 UPDATE避免两个
并发 resume 都过守卫后双 `Command(resume=...)` 损坏图(审评 #3抢不到已被并发抢走/非
awaiting→ 409。resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id
"""
request_id = getattr(request.state, "request_id", None)
await _require_project(project_repo, project_id)
job = await job_repo.get(job_id)
if job is None:
# 所有权校验(审评 #4job 不存在 **或** 不属于该项目 → 404同案避免跨项目枚举 job
if job is None or job.project_id != project_id:
raise AppError(ErrorCode.NOT_FOUND, f"job not found: {job_id}")
if job.status != STATUS_AWAITING:
raise AppError(
@@ -196,6 +205,17 @@ async def resume_chain(
{"job_status": job.status},
)
# 原子抢占 awaiting→running审评 #3抢不到并发竞态/已非 awaiting→ 409绝不调度。
claimed = await job_repo.claim_awaiting_to_running(job_id)
await session.commit() # 抢占须在 202 返回前持久化(防并发 resume 双调度)。
if claimed is None:
raise AppError(
ErrorCode.CONFLICT,
f"job {job_id} 已被并发续跑抢占或非 awaiting_input 态,不可重复续跑",
)
chain_key = claimed.kind if claimed.kind in SUPPORTED_CHAINS else "draft_volume"
accept_op = build_accept_op(
session_factory=session_factory,
digest_gateway_builder=digest_gateway_builder,
@@ -207,10 +227,10 @@ async def resume_chain(
job_id,
project_id=project_id,
user_id=STUB_OWNER_ID,
chain_key=job.kind if job.kind in SUPPORTED_CHAINS else "draft_volume",
chain_key=chain_key,
start_chapter_no=1, # resume 不重置区间:图从检查点续,初值不再使用。
count=1,
gateway=gateway,
chain_gateway_builder=chain_gateway_builder,
checkpointer_ctx=checkpointer_factory,
accept_op=accept_op,
chapter_repo_factory=_chapter_repo_factory,
@@ -227,12 +247,7 @@ async def resume_chain(
decision_count=len(body.decisions),
)
response.status_code = 202
return ChainRunAccepted(
job_id=job_id,
chain_key="draft_volume",
start_chapter_no=0,
count=0,
)
return ChainResumeAccepted(job_id=job_id, chain_key=chain_key)
def _chapter_repo_factory(session: AsyncSession) -> object:

View File

@@ -52,3 +52,13 @@ class ChainResumeRequest(BaseModel):
default_factory=list,
description="对 awaiting 章冲突的裁决清单(采纳/忽略/手改)",
)
class ChainResumeAccepted(BaseModel):
"""resume 受理回执202job_id + chain_key前端走 `GET /jobs/{id}` 轮询续跑进度)。
续跑无独立区间(图从检查点续),故不回显 start/count会是无意义哨兵值——只回 job 标识。
"""
job_id: uuid.UUID
chain_key: str

View File

@@ -32,7 +32,7 @@ from ww_core.domain.job_repo import SqlJobRepo
from ww_core.domain.review_repo import SqlReviewRepo
from ww_core.memory import assemble
from ww_core.memory.sql_repositories import sql_memory_repos
from ww_core.orchestrator import REVIEW_SPECS, GatewayRun
from ww_core.orchestrator import REVIEW_SPECS
from ww_core.orchestrator.chain import build_chain_graph, initial_chain_state
from ww_llm_gateway import Gateway
@@ -54,6 +54,10 @@ JOB_KIND_CHAIN = "chain"
# 「按 session 建 digest 网关」缝accept 节点在自建短事务里需 light 档网关跑终稿提炼。
DigestGatewayBuilder = Callable[[Any], Awaitable[Gateway]]
# 「按 session 建链网关」缝write/review 节点在自建短事务里现建网关ledger 绑节点 session
# 不变量 #1。BackgroundTask 跑时请求 session 已关闭,故必须用节点 session 重建网关。
GatewayChainBuilder = Callable[[Any], Awaitable[Gateway]]
def build_accept_op(
*,
@@ -178,7 +182,7 @@ async def run_chain_job(
chain_key: str,
start_chapter_no: int,
count: int,
gateway: GatewayRun,
chain_gateway_builder: GatewayChainBuilder,
checkpointer_ctx: CheckpointerCtx,
accept_op: AcceptOp,
chapter_repo_factory: ChapterRepoFactory,
@@ -193,6 +197,10 @@ async def run_chain_job(
否则 resume`graph.ainvoke(Command(resume=decisions), config)`),从 interrupt 续跑。
`thread_id = str(job_id)`:同 job 的初始/续跑落同一检查点 thread。
`chain_gateway_builder`非具体网关实例write/review 节点在自建短事务的**新鲜** session
上现建网关,使 usage_ledger 绑节点 session不变量 #1。**绝不**把请求阶段建的网关传进来——
BackgroundTask 跑时请求 session 已关闭,其 ledger.record() 会落到死 session 上被静默丢弃。
checkpointer 上下文Postgres 连接 / MemorySaver横跨整次 invoke本 runner 在 task
内打开/关闭它。异常一律被吞(后台任务边界),脱敏后置 job failed不冒泡崩进程。
"""
@@ -201,7 +209,7 @@ async def run_chain_job(
await _set_running(session_factory, job_id)
config: RunnableConfig = {"configurable": {"thread_id": str(job_id)}}
graph = build_chain_graph(
gateway,
chain_gateway_builder,
session_factory=session_factory,
memory_repos_factory=sql_memory_repos,
chapter_repo_factory=chapter_repo_factory,
@@ -236,8 +244,16 @@ async def run_chain_job(
written_count=len(result["written"]),
)
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed不冒泡。
log.error("chain_job_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
await _mark_failed(session_factory, job_id, _classify_job_error(exc), request_id)
# 日志只记脱敏文案 + 异常类型;绝不记 str(exc)(可能含 API key/连接串/LLM 输出,设计 §5
stored_error = _classify_job_error(exc)
log.error(
"chain_job_failed",
job_id=str(job_id),
request_id=request_id,
exc_type=type(exc).__name__,
error=stored_error,
)
await _mark_failed(session_factory, job_id, stored_error, request_id)
async def _set_running(session_factory: SessionFactory, job_id: uuid.UUID) -> None:
@@ -276,11 +292,12 @@ async def _mark_failed(
await SqlJobRepo(session).fail(job_id, error)
await session.commit()
except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。
# 同 §5不记 str(exc),只记异常类型(避免泄露内部细节)。
log.error(
"chain_job_fail_mark_failed",
job_id=str(job_id),
request_id=request_id,
error=str(exc),
exc_type=type(exc).__name__,
)
@@ -289,6 +306,7 @@ __all__ = [
"AcceptOp",
"CheckpointerCtx",
"DigestGatewayBuilder",
"GatewayChainBuilder",
"build_accept_op",
"run_chain_job",
]

View File

@@ -530,3 +530,27 @@ def get_digest_gateway_builder() -> GatewayDigestBuilder:
`app.dependency_overrides[get_digest_gateway_builder]` 注返回 mock 的 builder绝不联网
"""
return _digest_gateway_builder
# 「按 session 建链网关」缝类型(链 write/review 节点在 BackgroundTask 自建短事务的 session 建)。
GatewayChainBuilder = Callable[[AsyncSession], Awaitable[Gateway]]
def _chain_gateway_builder(session: AsyncSession) -> Awaitable[Gateway]:
"""链 write/review 节点在自建短事务里建按请求 tier 分派的链网关。
关键(守不变量 #1网关的 `SqlAlchemyLedgerSink` 必须绑**节点当前的** session——
BackgroundTask 跑时请求 session 已关闭,故不能复用请求阶段的网关,必须按节点新 session 现建,
否则 `gateway.run()` 的 `usage_ledger` 写会落到已死 session 上被静默丢弃(成本记账断裂)。
"""
return build_chain_gateway(session, SqlCredentialStore(session))
def get_chain_gateway_builder() -> GatewayChainBuilder:
"""返回「按 session 建链网关」的缝(链 write/review 节点在 BackgroundTask 内现建网关用)。
与 `get_chain_gateway`(请求阶段凭据探测,返单实例,仅做 503 拦截)不同:本缝供
`run_chain_job` 在节点自建的**新鲜** session 上重建网关,使 usage_ledger 落到活 session。
测试经 `app.dependency_overrides[get_chain_gateway_builder]` 注返 mock 的 builder绝不联网
"""
return _chain_gateway_builder