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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""所有权校验(审评 #4):job 属项目 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 检查处 409(CONFLICT)。
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "CONFLICT"
|
||||
assert len(_noop_run_chain_job) == 0
|
||||
|
||||
|
||||
# ---- 服务层 run_chain_job(直接 await,注入全 fake;job 状态由 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"],
|
||||
|
||||
Reference in New Issue
Block a user