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

@@ -184,6 +184,10 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
draft_text="第 N 章正文。", conflicts=conflicts, by_schema=_empty_by_schema()
)
async def gateway_builder(session: Any) -> Any:
"""按节点 session 建网关的替身(不变量 #1忽略 session恒返同一 mock 网关。"""
return gateway
def memory_repos_factory(session: Any) -> Any:
return object() # assemble 被 fake 替换,不实际用 repos
@@ -207,6 +211,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
return {
"gateway": gateway,
"gateway_builder": gateway_builder,
"session_factory": _session_factory,
"memory_repos_factory": memory_repos_factory,
"chapter_repo_factory": chapter_repo_factory,
@@ -221,7 +226,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
def _build(h: dict[str, Any], checkpointer: Any) -> Any:
return build_chain_graph(
h["gateway"],
h["gateway_builder"],
session_factory=h["session_factory"],
memory_repos_factory=h["memory_repos_factory"],
chapter_repo_factory=h["chapter_repo_factory"],
@@ -317,7 +322,7 @@ async def test_write_chapter_saves_collected_draft() -> None:
out = await write_chapter(
state,
gateway=h["gateway"],
gateway_builder=h["gateway_builder"],
session_factory=h["session_factory"],
memory_repos_factory=h["memory_repos_factory"],
chapter_repo_factory=h["chapter_repo_factory"],

View File

@@ -100,6 +100,14 @@ class FakeJobRepo:
row.result = dict(result)
return _view(row)
async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
"""原子抢占 awaiting→running内存替身仅当现态 awaiting 才抢到,否则 None"""
row = next((r for r in self.rows if r.id == job_id), None)
if row is None or row.status != STATUS_AWAITING:
return None
row.status = STATUS_RUNNING
return _view(row)
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
row = self._require(job_id)
row.status = STATUS_FAILED
@@ -173,6 +181,39 @@ async def test_fail_sets_failed_and_error() -> None:
assert failed.error == "boom"
# ---- 原子抢占 awaiting → running防并发 resume 竞态,审评 #3----
async def test_claim_awaiting_to_running_succeeds_once() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
await repo.set_awaiting(job.id, {"awaiting_chapter": 1})
claimed = await repo.claim_awaiting_to_running(job.id)
assert claimed is not None
assert claimed.status == STATUS_RUNNING
async def test_claim_awaiting_second_call_returns_none() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND)
await repo.set_awaiting(job.id, {"awaiting_chapter": 1})
first = await repo.claim_awaiting_to_running(job.id)
second = await repo.claim_awaiting_to_running(job.id) # 已 running → 抢不到
assert first is not None
assert second is None
async def test_claim_non_awaiting_returns_none() -> None:
repo: JobRepo = _repo()
job = await repo.create(PROJECT, KIND) # queued非 awaiting
assert await repo.claim_awaiting_to_running(job.id) is None
async def test_claim_absent_job_returns_none() -> None:
repo: JobRepo = _repo()
assert await repo.claim_awaiting_to_running(uuid.uuid4()) is None
# ---- progress (clamped) ----