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) ----

View File

@@ -39,6 +39,7 @@ class JobView(BaseModel):
model_config = {"frozen": True}
id: uuid.UUID
project_id: uuid.UUID | None = None
kind: str
status: str
progress: int = 0
@@ -69,6 +70,11 @@ class JobRepo(Protocol):
"""置 status=awaiting_input, result=<dict>(多章链 interrupt 暂停等裁决chain §5/§7"""
...
async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
"""**原子**抢占 awaiting_input → running返回抢到的 JobView未抢到不存在/非
awaiting/已被并发抢走)返 None。供 resume 端点防并发 resume 竞态chain §3/审评 #3"""
...
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
"""置 status=failed, error=<str>。"""
...
@@ -85,6 +91,7 @@ class JobRepo(Protocol):
def _to_view(row: Job) -> JobView:
return JobView(
id=row.id,
project_id=row.project_id,
kind=row.kind,
status=row.status,
progress=row.progress,
@@ -158,6 +165,34 @@ class SqlJobRepo:
await self._s.refresh(row)
return _to_view(row)
async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
"""原子 `awaiting_input → running`(条件 UPDATEreturning 行)。
防并发 resume 竞态(审评 #3两个并发 resume 仅一个能把 awaiting→running另一个
UPDATE 命中 0 行 → 返 None端点据此返 409。避免「两个 resume 都过 awaiting 守卫 →
同 thread_id 双 `Command(resume=...)` → 图损坏」。提交交调用方(端点事务)。
"""
result = await self._s.execute(
update(Job)
.where(Job.id == job_id, Job.status == STATUS_AWAITING)
.values(status=STATUS_RUNNING)
.returning(
Job.id, Job.project_id, Job.kind, Job.status, Job.progress, Job.result, Job.error
)
)
row = result.one_or_none()
if row is None:
return None
return JobView(
id=row.id,
project_id=row.project_id,
kind=row.kind,
status=row.status,
progress=row.progress,
result=row.result,
error=row.error,
)
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
row = await self._require(job_id)
row.status = STATUS_FAILED

View File

@@ -20,6 +20,7 @@ from .nodes import (
AssembleContext,
AssembleFn,
ChapterDraftRepo,
GatewayBuilder,
MemoryReposFactory,
ReviewRecordRepo,
SessionFactory,
@@ -41,6 +42,7 @@ __all__ = [
"AssembleFn",
"ChainState",
"ChapterDraftRepo",
"GatewayBuilder",
"MemoryReposFactory",
"ReviewRecordRepo",
"SessionFactory",

View File

@@ -22,13 +22,13 @@ from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt
from ww_agents import AgentSpec
from .._protocols import GatewayRun
from ..graph import REVIEW_SPECS
from . import nodes
from .nodes import (
AcceptChapterOp,
AssembleFn,
ChapterDraftRepo,
GatewayBuilder,
MemoryReposFactory,
ReviewRecordRepo,
SessionFactory,
@@ -46,7 +46,7 @@ ACCEPT_CHAPTER = "accept_chapter"
def build_chain_graph(
gateway: GatewayRun,
gateway_builder: GatewayBuilder,
*,
session_factory: SessionFactory,
memory_repos_factory: MemoryReposFactory,
@@ -60,7 +60,9 @@ def build_chain_graph(
"""构建并编译 `draft_volume` 多章链图。
节点(`write_chapter`/`review_chapter`/`decide`/`accept_chapter`)经默认参绑定把
`gateway`/`session_factory`/各 repo 工厂/`assemble`/`accept_op` 闭包绑入。
`gateway_builder`/`session_factory`/各 repo 工厂/`assemble`/`accept_op` 闭包绑入。
`gateway_builder` 而非具体网关实例write/review 节点在自建 session 上现建网关,使
usage_ledger 绑节点 session不变量 #1修复请求网关 session 已关闭致记账丢失的缺陷)。
条件边:`decide` 命中冲突 → `accept_chapter` 内 `interrupt()` 暂停交人;
`accept_chapter` 后判 `current_chapter_no <= last_chapter_no` 回 `write_chapter` 或 END。
@@ -70,7 +72,7 @@ def build_chain_graph(
async def _write(state: ChainState) -> dict[str, Any]:
return await nodes.write_chapter(
state,
gateway=gateway,
gateway_builder=gateway_builder,
session_factory=session_factory,
memory_repos_factory=memory_repos_factory,
chapter_repo_factory=chapter_repo_factory,
@@ -80,7 +82,7 @@ def build_chain_graph(
async def _review(state: ChainState) -> dict[str, Any]:
return await nodes.review_chapter(
state,
gateway=gateway,
gateway_builder=gateway_builder,
session_factory=session_factory,
memory_repos_factory=memory_repos_factory,
chapter_repo_factory=chapter_repo_factory,

View File

@@ -19,7 +19,7 @@ import structlog
from ww_agents import AgentSpec
from .._protocols import GatewayRun
from ..collect import collect_reviews, extract_conflicts
from ..collect import collect_reviews
from ..review_node import build_review_context, run_review
from ..state import ChapterState
from ..write_node import build_write_request
@@ -32,6 +32,11 @@ log = structlog.get_logger(__name__)
#: `session -> Repo`:节点自建短事务里从 session 造 repo仿端点依赖工厂
SessionFactory = Callable[[], AbstractAsyncContextManager[Any]]
#: `session -> Gateway`:按**节点当前 session** 建网关(不变量 #1
#: 关键:网关的 ledger sink 绑节点 session节点末尾 `commit()` 一并持久 usage_ledger 行——
#: 绝不能复用请求阶段建好的网关(其 session 在 BackgroundTask 跑时已关闭,记账行会被静默丢弃)。
GatewayBuilder = Callable[[Any], Awaitable[GatewayRun]]
class AssembleContext(Protocol):
"""`assemble` 产出的最小读形——只需 stable_core/volatile构造写章/审稿请求)。"""
@@ -103,7 +108,7 @@ AcceptChapterOp = Callable[..., Awaitable[None]]
async def write_chapter(
state: ChainState,
*,
gateway: GatewayRun,
gateway_builder: GatewayBuilder,
session_factory: SessionFactory,
memory_repos_factory: MemoryReposFactory,
chapter_repo_factory: Callable[[Any], ChapterDraftRepo],
@@ -114,11 +119,15 @@ async def write_chapter(
短事务、独立 session不跨章共享。state 不变(正文在 DB。返回空增量。
收集版写章复用 `build_write_request` 同一 prompt 组装路径stream=False → `gateway.run`
守不变量 #9stable_core 进缓存前缀);不确定性锁在网关后,注 mock 即可单测。
网关经 `gateway_builder(session)` 按**本节点 session** 现建(不变量 #1其 ledger sink
绑此 session末尾 `commit()` 一并持久 usage_ledger——绝不复用请求阶段已关闭 session 的网关。
"""
project_id = state["project_id"]
chapter_no = state["current_chapter_no"]
user_id = state["user_id"]
async with session_factory() as session:
gateway = await gateway_builder(session)
repos = memory_repos_factory(session)
context = await assemble(repos, project_id, chapter_no)
req = build_write_request(
@@ -145,7 +154,7 @@ async def write_chapter(
async def review_chapter(
state: ChainState,
*,
gateway: GatewayRun,
gateway_builder: GatewayBuilder,
session_factory: SessionFactory,
memory_repos_factory: MemoryReposFactory,
chapter_repo_factory: Callable[[Any], ChapterDraftRepo],
@@ -157,11 +166,15 @@ async def review_chapter(
复用现有 `run_review × specs` + `collect_reviews`(直接顺序跑各审,非起子图——链节点本身
已是图节点,无需嵌套图)。从 DB 重读本章草稿构审稿上下文(真相在表,不信 state
网关经 `gateway_builder(session)` 按**本节点 session** 现建(不变量 #1四审各调的
usage_ledger 行随节点末尾 `commit()` 一并持久——绝不复用请求阶段已关闭 session 的网关。
"""
project_id = state["project_id"]
chapter_no = state["current_chapter_no"]
user_id = state["user_id"]
async with session_factory() as session:
gateway = await gateway_builder(session)
repos = memory_repos_factory(session)
context = await assemble(repos, project_id, chapter_no)
chapter_repo = chapter_repo_factory(session)
@@ -302,9 +315,9 @@ __all__ = [
"MemoryReposFactory",
"ReviewRecordRepo",
"SessionFactory",
"GatewayBuilder",
"accept_chapter",
"decide",
"extract_conflicts",
"has_conflicts",
"review_chapter",
"write_chapter",