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

@@ -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"],