fix: audit batch 2 visibility (I-1 shadow dropped rows, I-2 partial policy failure, I-5 failure policy table)

- I-1: evaluate_shadow counts dropped rows by cause (unparseable /
  unknown_tier / explicit / duplicate) with the identity
  audit_rows_total == routed_task_n + dropped_total; the shadow
  endpoint passes created_at/id through and reports audit totals
  next to sample counts.
- I-2 (option b): parse logs a warning naming each tier dropped for
  an invalid mode/requires; load attaches partial_failures to the
  policy; the worker records a task-level
  worker_routing_policy_load_failed audit for partial failures
  (same action as full failure, detail says failure=partial);
  the shadow report carries the policy fingerprint, dropped tiers
  and partial failures (no task context on that path, so no
  task-level audit there).
- I-5: coordinator gate audit lookup failure now raises
  _CoordGateLookupFailed and the merge converts it to escalate
  (no requeue, no round consumed); persisted-model lookup failure
  and required-tools check failure each write a named audit row
  before falling through to dispatch/allow; audit-loop evaluate
  failure stays escalate (regression test).
- Test fixtures: coordinator suites build audit_log via AuthStore
  as production's first real audit does, since their stubbed
  _audit never creates the table.
This commit is contained in:
host committed 2026-11-04 18:02:22 +00:00
1 parent 44e0f4f1d6
commit a7f77c8a98
7 files changed
+648 -40

No files matched your search

+93 -13
View File
@@ -472,6 +472,17 @@ def _task_from_row(row):
}
class _CoordGateLookupFailed(Exception):
"""协调员门拒绝审计查询失败(Audit batch 2,I-5)。
由 _coord_gate_rejected_child_ids 在两次查询都失败时抛出,
调用方(_coord_audit_loop_merge)捕获后转 escalate——分类
依据缺失时绝不能回落到“空集 → 执行失败 → 推回”旧路。
抛错而不是返回哨兵值:哨兵会被任何忘记检查的调用方
当成集合误用,异常则无法被静默忽略。
"""
class Worker:
"""单机 worker:一轮认领一个 queued 任务并执行到底。
@@ -555,6 +566,12 @@ class Worker:
# 审计,让“策略已死”在运行期可见;models 侧的警告
# 日志见 load_routing_policy。
self.routing_policy_load_failed = False
# Audit batch 2 (I-2): 部分失败标志——策略可用,但有
# 来源失败或档被 fail-closed 弹出(见 models.py 的
# partial_failures / dropped_tiers)。run_one 里与
# 全失败同款 action 记任务级审计,detail 里区分
# partial 与 full,不混为一谈。
self.routing_policy_partial_failure = False
if isinstance(routing_policy, RoutingPolicy):
self.routing_policy = routing_policy
elif isinstance(routing_policy, dict):
@@ -573,6 +590,11 @@ class Worker:
self.routing_policy_load_failed = True
else:
raise ValueError("routing_policy 必须是 RoutingPolicy 或 dict")
if self.routing_policy is not None and (
getattr(self.routing_policy, "partial_failures", None)
or getattr(self.routing_policy, "dropped_tiers",
None)):
self.routing_policy_partial_failure = True
# 真实 fallback 换模型列表:默认空 = 关闭,只记事件不换模型
# (老行为,生产 worker 未配该参数时绝不突然多花模型钱)。
# 归一化:去空白、去重保序、去掉与主模型相同的项(换成同一
@@ -6139,8 +6161,15 @@ class Worker:
IDENTITY——此前注释称“没有 id 列”是错的,真正无 id
的只是测试手工建的表)。排序用 created_at 倒序、id
作 tie-break;缺 id 列的库(如手工建表)会炸掉第一
条查询,此时回落到仅按 created_at 排序再查一次,
仍失败才记警告日志返回空集,绝不静默吞掉。
条查询,此时回落到仅按 created_at 排序再查一次。
Audit batch 2(I-5):两次查询都失败时不再返回空集
——空集会被合流读成“没有门拒绝”,把门拒绝子任务
重分类为执行失败推进回循环。此时抛
_CoordGateLookupFailed,由合流捕获后转 escalate
(分类依据缺失→升级而非推回);警告日志仍记。
注意:生产里 audit_log 由 AuthStore 在首次审计时建
好,合流时表一定存在;缺表只会出现在 _audit 被桩
掉的测试里,那些夹具须按生产实际补建表(见测试)。
查询取 5000 行封顶:超限时回落标记仍可兜底,绝不
全表扫;封顶漏行只会导致执行失败走推回而非升级,
是 fail-open 向推回,不是向放行。
@@ -6174,7 +6203,7 @@ class Worker:
"coord gate audit lookup failed: %s", exc)
except Exception:
pass
return set()
raise _CoordGateLookupFailed(str(exc)) from exc
wanted = set(child_ids or [])
started = attempt_started_at or {}
found = set()
@@ -6327,10 +6356,26 @@ class Worker:
for child in children or []:
child_names[child["id"]] = child["name"]
rows = self._coord_child_rows(child_ids)
gate_rejected_ids = self._coord_gate_rejected_child_ids(
project_id, child_ids,
{cid: (rows.get(cid) or {}).get("started_at")
for cid in child_ids})
# Audit batch 2 (I-5): 门拒绝审计查不到时不猜——
# 分类依据缺失→升级而非推回。查询失败时清包、
# 记 escalated 审计、返回升级决策(父任务由调用
# 方置 failed,不重排、不耗轮次),与门拒绝升级
# 同款终局,只是原因不同。
try:
gate_rejected_ids = self._coord_gate_rejected_child_ids(
project_id, child_ids,
{cid: (rows.get(cid) or {}).get("started_at")
for cid in child_ids})
except _CoordGateLookupFailed as exc:
self._coord_clear_packets(task, child_ids)
self._audit(project_id,
"worker_coord_audit_loop_escalated",
"%s gate_lookup_failed=%s" % (task_id, exc))
return {
"decision": "escalate",
"error": "审计循环升级(门拒绝审计查询失败,分类依据"
"缺失,不推回)",
"gate_lookup_failed": True}
gate_results = {}
findings = []
gate_rejected = []
@@ -6856,6 +6901,8 @@ class Worker:
一个注定缺工具的任务。租户目录准备失败时不由本
方法定罪(返回 [],让 _docker_task_context 的
租户准备失败路径处理),避免同一失败被两处各说一遍。
Audit batch 2(I-5):返回 [] 放行时同时记一条审计行
(worker_required_tools_check_failed),放行可见。
"""
tools = task.get("agent_tools") or []
if not tools:
@@ -6873,11 +6920,20 @@ class Worker:
except Exception as exc:
# 不由本方法定罪,但绝不静默吞掉:记日志,真正
# 的租户准备失败由 _docker_task_context 报错。
# Audit batch 2(I-5):放行仍是放行(返回 [] 不变),
# 但同时记一条点名依据缺失的审计行,让“工具门
# 因读不到租户配置而放行”在审计里可见;审计
# 本身 best-effort,写失败不改放行决策。
try:
self.logger.warning(
"required tools check skipped: %s", exc)
except Exception:
pass
if task.get("project_id"):
self._audit(task["project_id"],
"worker_required_tools_check_failed",
"%s error=%s" % (
task.get("id") or "-", exc))
return []
return _missing_tools(tools, config_text)
@@ -6901,7 +6957,11 @@ class Worker:
policy 行只可能来自该任务曾经模型为空的那次派发,
也就是写回的来源。
查找失败(DB 异常)时返回 False 并记警告日志:复核查
不到依据就跳过,而不是拦任务。
不到依据就跳过,而不是拦任务。Audit batch 2(I-5):
跳过不再静默——同时记一条点名依据缺失的审计行
(worker_persisted_model_lookup_failed),让“复核
因查不到依据而放行”在审计里可见;审计本身
best-effort,写失败不改放行决策。
残留缺口措辞(9.5-code,M1):fixes3 时期由 shadow /
worker_default 决策写回的旧模型既无 persisted 行又
@@ -6928,6 +6988,12 @@ class Worker:
task_id=task_id, project_id=project_id,
status="warning",
message="persisted model lookup failed: %s" % exc)
# Audit batch 2 (I-5): 依据缺失也要在审计里点名,
# 不能只有日志——日志与审计是两条可见性通道。
# _audit 本身 best-effort,写失败不改返回 False。
self._audit(project_id,
"worker_persisted_model_lookup_failed",
"%s error=%s" % (task_id, exc))
return False
for row in rows:
if row[0] != "worker_model_persisted":
@@ -7100,14 +7166,28 @@ class Worker:
task = self._apply_audit_loop_packet(task)
self._audit(task["project_id"], "worker_claim",
"%s executor=%s" % (task_id, self._executor_name()))
# Audit fix 3(I3):策略已死时每任务记一条审计,
# 否则运营方只能靠日志猜“策略为什么没生效”。
# _audit 本身 best-effort,不影响派发。
# Audit fix 3(I3)+ batch 2(I-2):策略已死或部分
# 失败时每任务记一条审计,否则运营方只能靠日志猜
# “策略为什么没生效”。全失败与部分失败用同款
# action、detail 里区分 failure=full/partial 并附
# 丢弃档与来源失败清单;_audit 本身 best-effort,
# 不影响派发。
if getattr(self, "routing_policy_load_failed", False):
self._audit(task["project_id"],
"worker_routing_policy_load_failed",
"%s project=%s" % (task_id,
task["project_id"]))
"%s project=%s failure=full" % (
task_id, task["project_id"]))
elif getattr(self, "routing_policy_partial_failure", False):
_policy = self.routing_policy
self._audit(task["project_id"],
"worker_routing_policy_load_failed",
"%s project=%s failure=partial dropped_tiers=%s"
" partial_failures=%s" % (
task_id, task["project_id"],
",".join(getattr(
_policy, "dropped_tiers", []) or []),
"; ".join(getattr(
_policy, "partial_failures", []) or [])))
# log_event 只接受 task_id/project_id/status/message 四个
# 字段,executor 只进审计 detail,不进日志调用(否则
# TypeError 会炸掉整个 run_one)。