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:
@@ -28,6 +28,7 @@ from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.chain import (
|
||||
ChainResumeAccepted,
|
||||
ChainResumeRequest,
|
||||
ChainRunAccepted,
|
||||
ChainRunRequest,
|
||||
@@ -44,8 +45,10 @@ from ww_api.services.chain_runner import (
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.project_deps import (
|
||||
GatewayChainBuilder,
|
||||
GatewayDigestBuilder,
|
||||
get_chain_gateway,
|
||||
get_chain_gateway_builder,
|
||||
get_digest_gateway_builder,
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
@@ -66,6 +69,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||||
CheckpointerFactoryDep = Annotated[CheckpointerFactory, Depends(get_checkpointer_factory)]
|
||||
DigestBuilderDep = Annotated[GatewayDigestBuilder, Depends(get_digest_gateway_builder)]
|
||||
ChainBuilderDep = Annotated[GatewayChainBuilder, Depends(get_chain_gateway_builder)]
|
||||
|
||||
_RUN_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "项目或链 key 不存在"},
|
||||
@@ -97,11 +101,12 @@ async def run_chain(
|
||||
background_tasks: BackgroundTasks,
|
||||
project_repo: ProjectRepoDep,
|
||||
job_repo: JobRepoDep,
|
||||
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503)
|
||||
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503);不用于实际 LLM 调用
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
checkpointer_factory: CheckpointerFactoryDep,
|
||||
digest_gateway_builder: DigestBuilderDep,
|
||||
chain_gateway_builder: ChainBuilderDep,
|
||||
) -> ChainRunAccepted:
|
||||
"""发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。
|
||||
|
||||
@@ -132,7 +137,7 @@ async def run_chain(
|
||||
chain_key=chain_key,
|
||||
start_chapter_no=body.start_chapter_no,
|
||||
count=body.count,
|
||||
gateway=gateway,
|
||||
chain_gateway_builder=chain_gateway_builder,
|
||||
checkpointer_ctx=checkpointer_factory,
|
||||
accept_op=accept_op,
|
||||
chapter_repo_factory=_chapter_repo_factory,
|
||||
@@ -172,22 +177,26 @@ async def resume_chain(
|
||||
background_tasks: BackgroundTasks,
|
||||
project_repo: ProjectRepoDep,
|
||||
job_repo: JobRepoDep,
|
||||
gateway: ChainGatewayDep,
|
||||
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503);不用于实际 LLM 调用
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
checkpointer_factory: CheckpointerFactoryDep,
|
||||
digest_gateway_builder: DigestBuilderDep,
|
||||
) -> ChainRunAccepted:
|
||||
chain_gateway_builder: ChainBuilderDep,
|
||||
) -> ChainResumeAccepted:
|
||||
"""裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。
|
||||
|
||||
项目不存在 → 404;job 不存在 → 404;非 awaiting 态 → 409(CONFLICT)。
|
||||
resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id=job_id)。
|
||||
项目不存在 → 404;job 不存在 / 不属于该项目 → 404(同案,不枚举);非 awaiting 态 →
|
||||
409(CONFLICT)。`awaiting→running` 在 HTTP 处理器内**原子抢占**(条件 UPDATE),避免两个
|
||||
并发 resume 都过守卫后双 `Command(resume=...)` 损坏图(审评 #3);抢不到(已被并发抢走/非
|
||||
awaiting)→ 409。resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
await _require_project(project_repo, project_id)
|
||||
job = await job_repo.get(job_id)
|
||||
if job is None:
|
||||
# 所有权校验(审评 #4):job 不存在 **或** 不属于该项目 → 404(同案,避免跨项目枚举 job)。
|
||||
if job is None or job.project_id != project_id:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"job not found: {job_id}")
|
||||
if job.status != STATUS_AWAITING:
|
||||
raise AppError(
|
||||
@@ -196,6 +205,17 @@ async def resume_chain(
|
||||
{"job_status": job.status},
|
||||
)
|
||||
|
||||
# 原子抢占 awaiting→running(审评 #3):抢不到(并发竞态/已非 awaiting)→ 409,绝不调度。
|
||||
claimed = await job_repo.claim_awaiting_to_running(job_id)
|
||||
await session.commit() # 抢占须在 202 返回前持久化(防并发 resume 双调度)。
|
||||
if claimed is None:
|
||||
raise AppError(
|
||||
ErrorCode.CONFLICT,
|
||||
f"job {job_id} 已被并发续跑抢占或非 awaiting_input 态,不可重复续跑",
|
||||
)
|
||||
|
||||
chain_key = claimed.kind if claimed.kind in SUPPORTED_CHAINS else "draft_volume"
|
||||
|
||||
accept_op = build_accept_op(
|
||||
session_factory=session_factory,
|
||||
digest_gateway_builder=digest_gateway_builder,
|
||||
@@ -207,10 +227,10 @@ async def resume_chain(
|
||||
job_id,
|
||||
project_id=project_id,
|
||||
user_id=STUB_OWNER_ID,
|
||||
chain_key=job.kind if job.kind in SUPPORTED_CHAINS else "draft_volume",
|
||||
chain_key=chain_key,
|
||||
start_chapter_no=1, # resume 不重置区间:图从检查点续,初值不再使用。
|
||||
count=1,
|
||||
gateway=gateway,
|
||||
chain_gateway_builder=chain_gateway_builder,
|
||||
checkpointer_ctx=checkpointer_factory,
|
||||
accept_op=accept_op,
|
||||
chapter_repo_factory=_chapter_repo_factory,
|
||||
@@ -227,12 +247,7 @@ async def resume_chain(
|
||||
decision_count=len(body.decisions),
|
||||
)
|
||||
response.status_code = 202
|
||||
return ChainRunAccepted(
|
||||
job_id=job_id,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=0,
|
||||
count=0,
|
||||
)
|
||||
return ChainResumeAccepted(job_id=job_id, chain_key=chain_key)
|
||||
|
||||
|
||||
def _chapter_repo_factory(session: AsyncSession) -> object:
|
||||
|
||||
Reference in New Issue
Block a user