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:
@@ -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"],
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user