Audit batch 5: I-9, I-12, I-14, N-1..N-12, single-task wire single-transaction
This commit is contained in:
1 parent
cdac8039b3
commit
2adc605373
7 files changed
+1274
-365
No files matched your search
@@ -216,6 +216,17 @@ def parse_agent_tools_column(raw):
|
||||
parse logic therefore changes no observable behaviour on
|
||||
either side; the old server/worker defaults (None vs []) were
|
||||
only different spellings of the same dispatch outcome.
|
||||
Audit batch 5 (N-9, deliberate choice: document, do not filter):
|
||||
the parser guarantees list shape only, NOT item types — a dirty
|
||||
row like ["a", 1] is returned as-is. Downstream tolerates
|
||||
non-string items, which is why filtering here would be churn,
|
||||
not a fix: the worker required-tools gate only formats items
|
||||
with "%s" (missing_required_tools) and compares them with ==
|
||||
(deny check), the server only JSON-dumps the list, and no use
|
||||
site calls a str method on an item. Filtering at parse time
|
||||
would instead change serialization of old dirty rows (the
|
||||
server today echoes them exactly), so the loose contract stays
|
||||
and is locked by test.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
|
||||
+39
-38
@@ -224,6 +224,28 @@ def _validate_gate_results(gate_results):
|
||||
raise ValueError("gate_results must map gate name to bool")
|
||||
|
||||
|
||||
def _escalate(task_id, prev_round, passed_gates, criteria, reason,
|
||||
regression=(), missing_gates=(), weakened=()):
|
||||
"""升级决策 dict(审计批 5,N-8:纯结构整理,行为不变)。
|
||||
|
||||
evaluate() 内四处 escalate 返回原来各写一遍同样的 dict
|
||||
骨架(new_state + decision/round/packet/reason + 三个清单),
|
||||
只有 passed_gates / criteria / reason / 清单内容不同;抽成
|
||||
助手后各处只传不同项。new_state 的 round 恒为 prev_round
|
||||
(升级不推进轮次)、last_decision 恒为 escalate。
|
||||
"""
|
||||
new_state = LoopState(
|
||||
task_id, round=prev_round, passed_gates=sorted(passed_gates),
|
||||
criteria=criteria, last_decision="escalate")
|
||||
return {
|
||||
"decision": "escalate", "round": prev_round, "packet": None,
|
||||
"new_state": new_state, "reason": reason,
|
||||
"regression": list(regression),
|
||||
"missing_gates": list(missing_gates),
|
||||
"weakened": list(weakened),
|
||||
}
|
||||
|
||||
|
||||
def evaluate(state, task_id, gate_results, findings=None, fixed=None):
|
||||
"""纯函数:一轮审计的决策。
|
||||
|
||||
@@ -269,17 +291,10 @@ def evaluate(state, task_id, gate_results, findings=None, fixed=None):
|
||||
# 全量回归:上一轮通过的门本轮必须重跑,缺跑即升级。
|
||||
missing_gates = sorted(g for g in prev_passed if g not in gate_results)
|
||||
if missing_gates:
|
||||
new_state = LoopState(
|
||||
task_id, round=prev_round,
|
||||
passed_gates=sorted(
|
||||
g for g, v in gate_results.items() if v),
|
||||
criteria=prev_criteria, last_decision="escalate")
|
||||
return {
|
||||
"decision": "escalate", "round": prev_round, "packet": None,
|
||||
"new_state": new_state, "reason": "gate_missing",
|
||||
"regression": [], "missing_gates": missing_gates,
|
||||
"weakened": [],
|
||||
}
|
||||
return _escalate(
|
||||
task_id, prev_round,
|
||||
(g for g, v in gate_results.items() if v),
|
||||
prev_criteria, "gate_missing", missing_gates=missing_gates)
|
||||
|
||||
passed_now = {g for g, v in gate_results.items() if v}
|
||||
failed_now = {g for g, v in gate_results.items() if not v}
|
||||
@@ -310,15 +325,11 @@ def evaluate(state, task_id, gate_results, findings=None, fixed=None):
|
||||
else:
|
||||
weakened.append(fid)
|
||||
if weakened:
|
||||
new_state = LoopState(
|
||||
task_id, round=prev_round, passed_gates=sorted(passed_now),
|
||||
criteria=prev_criteria, last_decision="escalate")
|
||||
return {
|
||||
"decision": "escalate", "round": prev_round, "packet": None,
|
||||
"new_state": new_state, "reason": "standards_weakened",
|
||||
"regression": [f.to_dict() for f in regression],
|
||||
"missing_gates": [], "weakened": sorted(weakened),
|
||||
}
|
||||
return _escalate(
|
||||
task_id, prev_round, passed_now, prev_criteria,
|
||||
"standards_weakened",
|
||||
regression=[f.to_dict() for f in regression],
|
||||
weakened=sorted(weakened))
|
||||
|
||||
# 新标准 = 未解决的旧标准 + 本轮新 finding(含回归)。
|
||||
for finding in all_findings:
|
||||
@@ -348,26 +359,16 @@ def evaluate(state, task_id, gate_results, findings=None, fixed=None):
|
||||
# 转 escalate),新行为后看到的是 gate_failed_without_
|
||||
# finding 升级——原因更精确,不是行为倒退。
|
||||
if failed_now and not all_findings:
|
||||
new_state = LoopState(
|
||||
task_id, round=prev_round, passed_gates=sorted(passed_now),
|
||||
criteria=new_criteria, last_decision="escalate")
|
||||
return {
|
||||
"decision": "escalate", "round": prev_round, "packet": None,
|
||||
"new_state": new_state, "reason": "gate_failed_without_finding",
|
||||
"regression": [f.to_dict() for f in regression],
|
||||
"missing_gates": [], "weakened": [],
|
||||
}
|
||||
return _escalate(
|
||||
task_id, prev_round, passed_now, new_criteria,
|
||||
"gate_failed_without_finding",
|
||||
regression=[f.to_dict() for f in regression])
|
||||
|
||||
if prev_round >= MAX_PUSHBACKS:
|
||||
new_state = LoopState(
|
||||
task_id, round=prev_round, passed_gates=sorted(passed_now),
|
||||
criteria=new_criteria, last_decision="escalate")
|
||||
return {
|
||||
"decision": "escalate", "round": prev_round, "packet": None,
|
||||
"new_state": new_state, "reason": "max_rounds_exceeded",
|
||||
"regression": [f.to_dict() for f in regression],
|
||||
"missing_gates": [], "weakened": [],
|
||||
}
|
||||
return _escalate(
|
||||
task_id, prev_round, passed_now, new_criteria,
|
||||
"max_rounds_exceeded",
|
||||
regression=[f.to_dict() for f in regression])
|
||||
|
||||
new_round = prev_round + 1
|
||||
packet = PushbackPacket(task_id, new_round, all_findings, new_criteria)
|
||||
|
||||
+24
-19
@@ -98,6 +98,25 @@ def _tier_dropped_reason(raw_entry):
|
||||
return "unrecognised tier entry (expected string or dict)"
|
||||
|
||||
|
||||
def _empty_report(warnings, tiers, dropped, unknown, gateway):
|
||||
"""无效策略的空报告(审计批 5,N-10:纯结构整理,行为不变)。
|
||||
|
||||
dry_run_policy_document 的两处早期返回(非对象文档、解析
|
||||
出空策略)原来各写一遍同样的 dict 骨架,只有 warnings 不同;
|
||||
抽成助手后各处只传不同项。传入的列表按引用进报告(与原来
|
||||
直接写字面量时同一语义),不拷贝。
|
||||
"""
|
||||
return {
|
||||
"valid": False, "blocked": True, "version": None,
|
||||
"content_fingerprint": None,
|
||||
"global_mode": None, "global_mode_source": None,
|
||||
"tiers": tiers, "dropped_tiers": dropped,
|
||||
"unknown_tiers": unknown, "warnings": warnings,
|
||||
"would_activate": [], "would_shadow": [], "would_off": [],
|
||||
"gateway": gateway,
|
||||
}
|
||||
|
||||
|
||||
def dry_run_policy_document(data, env_mode_override=None,
|
||||
gateway_model=None):
|
||||
"""Dry-run one merged policy document (pure, no IO).
|
||||
@@ -118,17 +137,10 @@ def dry_run_policy_document(data, env_mode_override=None,
|
||||
if gateway_model else "env_unset"),
|
||||
"tiers": {}}
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"valid": False, "blocked": True, "version": None,
|
||||
"content_fingerprint": None,
|
||||
"global_mode": None, "global_mode_source": None,
|
||||
"tiers": tiers_report, "dropped_tiers": dropped,
|
||||
"unknown_tiers": unknown,
|
||||
"warnings": ["policy document is not an object; "
|
||||
return _empty_report(
|
||||
["policy document is not an object; "
|
||||
"the loader would ignore it entirely"],
|
||||
"would_activate": [], "would_shadow": [], "would_off": [],
|
||||
"gateway": _gw,
|
||||
}
|
||||
tiers_report, dropped, unknown, _gw)
|
||||
raw_tiers = data.get("tiers")
|
||||
if isinstance(raw_tiers, dict):
|
||||
for name in sorted(raw_tiers):
|
||||
@@ -142,15 +154,8 @@ def dry_run_policy_document(data, env_mode_override=None,
|
||||
"policy is unusable (no usable tiers, or global mode "
|
||||
"outside off/shadow/active); the loader would ignore it "
|
||||
"entirely and workers would keep the worker default")
|
||||
return {
|
||||
"valid": False, "blocked": True, "version": None,
|
||||
"content_fingerprint": None,
|
||||
"global_mode": None, "global_mode_source": None,
|
||||
"tiers": tiers_report, "dropped_tiers": dropped,
|
||||
"unknown_tiers": unknown, "warnings": warnings,
|
||||
"would_activate": [], "would_shadow": [], "would_off": [],
|
||||
"gateway": _gw,
|
||||
}
|
||||
return _empty_report(
|
||||
warnings, tiers_report, dropped, unknown, _gw)
|
||||
global_mode = policy.mode
|
||||
global_source = "policy"
|
||||
if env_mode_override:
|
||||
|
||||
+82
-39
@@ -4200,7 +4200,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
# cost_records 表只在 CostStore 首次初始化时建:新鲜
|
||||
# 库上直接查会报 no such table,报告端点不应因此 500。
|
||||
# 先建表(幂等),再读;建表失败时成本按空算、报告
|
||||
# 里 token/usd 为 0,不假装成本已计。
|
||||
# 里 token/usd 为 0,不假装成本已计。审计批 5(N-1):
|
||||
# 下面成本 SELECT 同样加 except 兜底(此前只有 finally、
|
||||
# 没有 except,建表失败会直接抛到 handler 层),失败时
|
||||
# 成本按空算并在报告里标 cost_unavailable。
|
||||
try:
|
||||
_cost_store = CostStore(self.server.db_path)
|
||||
try:
|
||||
@@ -4238,11 +4241,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
" WHERE project_id = ?",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
cost_unavailable = False
|
||||
try:
|
||||
cost_rows = conn.execute(
|
||||
"SELECT task_id, total_tokens, usd_micros"
|
||||
" FROM cost_records WHERE project_id = ?",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
# 审计批 5(N-1):成本查询失败(通常是建表失败后
|
||||
# 表仍不存在)时成本按空算,不抛到 handler 层;
|
||||
# 报告里的 cost_unavailable 标明成本不可用。
|
||||
cost_rows = []
|
||||
cost_unavailable = True
|
||||
finally:
|
||||
conn.close()
|
||||
tasks_by_id = {}
|
||||
@@ -4275,6 +4286,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
tasks_by_id, costs_by_task, explicit_n=explicit_n)
|
||||
report["project_id"] = project_id
|
||||
report["truncated"] = truncated
|
||||
report["cost_unavailable"] = cost_unavailable
|
||||
# 审计批 5(N-5):截断取最早 10000 行(ORDER BY
|
||||
# created_at ASC),与协调员门查询取最新 5000 行方向
|
||||
# 相反——调用方须知样本是哪一段:同时返回样本时间窗
|
||||
# (保留样本的最早/最晚 created_at;无样本时为 None)。
|
||||
if audit_rows:
|
||||
report["sample_window"] = {
|
||||
"earliest_created_at": audit_rows[0]["created_at"],
|
||||
"latest_created_at": audit_rows[-1]["created_at"]}
|
||||
else:
|
||||
report["sample_window"] = {
|
||||
"earliest_created_at": None, "latest_created_at": None}
|
||||
# 显式失败清单:解析 worker_capability_gateway_explicit
|
||||
# 行(格式:task_id model=.. tier=.. mode=.. missing=..
|
||||
# unknown=.. blocked=..;fix4 之前的行无 blocked 项,
|
||||
@@ -4570,6 +4593,57 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
for path, size in saved],
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _resolve_create_agent_definition(body):
|
||||
"""建任务的 agent 定义解析(审计批 5,I-9③抽取,行为逐字不变)。
|
||||
|
||||
原 _create_task 内 agent/agent_name 解析段平移:返回
|
||||
(definition, error)——error 非空时调用方按原样发 400 且
|
||||
不建任务;definition 为 None 且 error 为空表示未给定义。
|
||||
副作用:无库写、无响应发送(400 由调用方发,与原地一致)。
|
||||
"""
|
||||
if body.get("agent") is not None \
|
||||
and body.get("agent_name") is not None:
|
||||
return None, "agent and agent_name cannot both be used"
|
||||
if body.get("agent") is not None \
|
||||
and body.get("agent_version") is not None:
|
||||
return None, ("agent_version is only for agent_name;"
|
||||
" an inline agent carries its own version")
|
||||
definition = None
|
||||
if body.get("agent") is not None:
|
||||
if not isinstance(body.get("agent"), dict):
|
||||
return None, "agent must be an object"
|
||||
try:
|
||||
definition = _parse_agent_definition(
|
||||
body.get("agent"),
|
||||
known_skills=_agent_known_skills())
|
||||
except ValueError as exc:
|
||||
return None, str(exc)
|
||||
if body.get("agent_name") is not None:
|
||||
try:
|
||||
definition = _resolve_agent(
|
||||
body.get("agent_name"),
|
||||
version=body.get("agent_version"),
|
||||
agents_dir=_agent_default_dir(),
|
||||
known_skills=_agent_known_skills())
|
||||
except ValueError as exc:
|
||||
return None, str(exc)
|
||||
return definition, None
|
||||
|
||||
@staticmethod
|
||||
def _validate_create_audit_loop(body):
|
||||
"""建任务的 audit_loop 开关校验(审计批 5,I-9③抽取,行为逐字不变)。
|
||||
|
||||
原 _create_task 内审计循环接线校验段平移:只认 True/1
|
||||
(其它真值一律错,不猜)。返回 (audit_loop_val, error)——
|
||||
error 非空时调用方按原样发 400 且不建任务。
|
||||
"""
|
||||
if body.get("audit_loop") is None:
|
||||
return 0, None
|
||||
if body.get("audit_loop") not in (True, 1):
|
||||
return None, "audit_loop must be true or 1"
|
||||
return 1, None
|
||||
|
||||
def _create_task(self, user_id):
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
@@ -4606,38 +4680,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
# 优先于定义;解析出的字段落进已有列,定义身份与
|
||||
# 指令落进 agent_name/agent_version/agent_instructions
|
||||
# 列(v2),prompt 不再带头。
|
||||
definition = None
|
||||
if body.get("agent") is not None \
|
||||
and body.get("agent_name") is not None:
|
||||
self._send_json(400, {
|
||||
"error": "agent and agent_name cannot both be used"})
|
||||
return
|
||||
if body.get("agent") is not None \
|
||||
and body.get("agent_version") is not None:
|
||||
self._send_json(400, {
|
||||
"error": "agent_version is only for agent_name;"
|
||||
" an inline agent carries its own version"})
|
||||
return
|
||||
if body.get("agent") is not None:
|
||||
if not isinstance(body.get("agent"), dict):
|
||||
self._send_json(400, {"error": "agent must be an object"})
|
||||
return
|
||||
try:
|
||||
definition = _parse_agent_definition(
|
||||
body.get("agent"),
|
||||
known_skills=_agent_known_skills())
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
if body.get("agent_name") is not None:
|
||||
try:
|
||||
definition = _resolve_agent(
|
||||
body.get("agent_name"),
|
||||
version=body.get("agent_version"),
|
||||
agents_dir=_agent_default_dir(),
|
||||
known_skills=_agent_known_skills())
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
definition, _agent_err = \
|
||||
self._resolve_create_agent_definition(body)
|
||||
if _agent_err:
|
||||
self._send_json(400, {"error": _agent_err})
|
||||
return
|
||||
depends_on = body.get("depends_on") or []
|
||||
if isinstance(depends_on, str):
|
||||
@@ -4976,13 +5022,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
# 就不写 audit_loop 列(默认 0),父任务的循环只记在
|
||||
# 父任务上,子任务包只是前言载体。goal 任务允许(goal
|
||||
# 校验门是单任务接线要消费的确定性门之一)。
|
||||
audit_loop_val = 0
|
||||
if body.get("audit_loop") is not None:
|
||||
if body.get("audit_loop") not in (True, 1):
|
||||
self._send_json(400, {
|
||||
"error": "audit_loop must be true or 1"})
|
||||
audit_loop_val, _al_err = self._validate_create_audit_loop(body)
|
||||
if _al_err:
|
||||
self._send_json(400, {"error": _al_err})
|
||||
return
|
||||
audit_loop_val = 1
|
||||
agent_name_val = definition.name if definition else None
|
||||
agent_version_val = definition.version if definition else None
|
||||
agent_instructions_val = (
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
"""审计批 5(I-9、I-14、N-1~N-12、I-12、事务分离)测试。
|
||||
|
||||
只用注入的假 runner 与 init_db 的本地库,不跑真实模型回合、
|
||||
不联网。按任务书:新测试 + 相关旧套件确认无回归;本文件
|
||||
只覆盖本批的新行为与新契约,旧行为由旧套件锁定。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from cloud.agent_def import (
|
||||
missing_required_tools,
|
||||
parse_agent_tools_column,
|
||||
)
|
||||
from cloud.audit_loop import Finding
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.models import parse_routing_policy
|
||||
from cloud.routing_dryrun import dry_run_policy_document
|
||||
from cloud.server import CloudControlPlaneHandler, create_server, init_db
|
||||
from cloud.worker import Worker
|
||||
import cloud.worker as worker_module
|
||||
import cloud.server as server_module
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.agent_def import (
|
||||
missing_required_tools,
|
||||
parse_agent_tools_column,
|
||||
)
|
||||
from cloud.audit_loop import Finding
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.models import parse_routing_policy
|
||||
from cloud.routing_dryrun import dry_run_policy_document
|
||||
from cloud.server import CloudControlPlaneHandler, create_server, init_db
|
||||
from cloud.worker import Worker
|
||||
import cloud.worker as worker_module
|
||||
import cloud.server as server_module
|
||||
|
||||
|
||||
def _completed_runner(cmd):
|
||||
name = cmd[cmd.index("--name") + 1] if "dispatch" in cmd else "t"
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"task": name, "status": "completed",
|
||||
"text": "done"}),
|
||||
stderr="")
|
||||
|
||||
|
||||
def _insert_task(db_path, task_id, status="queued", audit_loop=0,
|
||||
goal_mode=0, goal=None, verify_command=None,
|
||||
goal_max_rounds=None, model=None, packet=None):
|
||||
now = time.time()
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" idempotency_key, depends_on, priority, created_by,"
|
||||
" created_at, updated_at, last_heartbeat, audit_loop,"
|
||||
" goal_mode, goal, verify_command, goal_max_rounds, model,"
|
||||
" audit_loop_packet)"
|
||||
" VALUES(?, 'p1', 'do work', ?, NULL, '[]', 0, 'tester',"
|
||||
" ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(task_id, status, now, now, audit_loop, goal_mode, goal,
|
||||
verify_command, goal_max_rounds, model, packet))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _set_status(db_path, task_id, status):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
|
||||
(status, task_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _row(db_path, task_id):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT status, audit_loop_packet, goal, verify_command,"
|
||||
" goal_max_rounds, model FROM tasks WHERE id = ?",
|
||||
(task_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _state_row(db_path, task_id):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT round, last_decision FROM task_audit_loop_state"
|
||||
" WHERE task_id = ?", (task_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_state(db_path, task_id, round_no, last_decision):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS task_audit_loop_state("
|
||||
"task_id TEXT PRIMARY KEY, round INTEGER,"
|
||||
" passed_gates TEXT, criteria TEXT, last_decision TEXT,"
|
||||
" updated_at DOUBLE PRECISION)")
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO task_audit_loop_state"
|
||||
"(task_id, round, passed_gates, criteria, last_decision,"
|
||||
" updated_at) VALUES(?, ?, ?, ?, ?, ?)",
|
||||
(task_id, round_no, '["execution"]', '{}', last_decision,
|
||||
time.time()))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class _FailingExecuteConn:
|
||||
"""注入失败的连接包装:指定 SQL 片段一律抛错,其余透传。"""
|
||||
|
||||
def __init__(self, real, needle):
|
||||
self._real = real
|
||||
self._needle = needle
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
if self._needle in sql:
|
||||
raise RuntimeError("injected store failure")
|
||||
return self._real.execute(sql, params)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
|
||||
class _WorkerCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
init_db(self.db_path)
|
||||
self.audits = []
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _worker(self, **kwargs):
|
||||
kwargs.setdefault("env", {"XCM_API_KEY": "test-placeholder"})
|
||||
kwargs.setdefault("max_retries", 0)
|
||||
kwargs.setdefault("retry_backoff_seconds", 0)
|
||||
kwargs.setdefault("runner", _completed_runner)
|
||||
worker = Worker(self.db_path, "/fake/host.py", **kwargs)
|
||||
worker._audit = lambda p, a, d="": self.audits.append((p, a, d))
|
||||
return worker
|
||||
|
||||
def _actions(self):
|
||||
return [a for _, a, _ in self.audits]
|
||||
|
||||
|
||||
class FingerprintTestCase(unittest.TestCase):
|
||||
def test_fallback_models_change_fingerprint(self):
|
||||
# I-14①:指纹宣称含 fallback_models,变化必须敏感。
|
||||
base = {"mode": "shadow", "tiers": {"default": "m1"},
|
||||
"model_capabilities": {"m1": {"tool_calling": True}}}
|
||||
a = parse_routing_policy(base)
|
||||
b = parse_routing_policy(
|
||||
dict(base, fallback_models=["m2"]))
|
||||
self.assertNotEqual(a.content_fingerprint(),
|
||||
b.content_fingerprint())
|
||||
c = parse_routing_policy(dict(base, fallback_models=["m2"]))
|
||||
self.assertEqual(b.content_fingerprint(),
|
||||
c.content_fingerprint())
|
||||
|
||||
|
||||
class WireTxTestCase(_WorkerCase):
|
||||
def _patched_worker(self, needle):
|
||||
real_connect = worker_module._db_connect
|
||||
|
||||
def _connect(path):
|
||||
return _FailingExecuteConn(real_connect(path), needle)
|
||||
|
||||
patcher = mock.patch.object(worker_module, "_db_connect",
|
||||
_connect)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
return self._worker()
|
||||
|
||||
def test_pushback_store_failure_rolls_back_state(self):
|
||||
# 事务分离:推回 UPDATE 失败时状态保存一并回滚,任务不
|
||||
# 以“已推回状态 + failed 任务行”的半截态落地。
|
||||
_insert_task(self.db_path, "TP", status="failed", audit_loop=1)
|
||||
worker = self._patched_worker("SET status = 'queued'")
|
||||
task = worker.get_task("TP")
|
||||
final, _, decision = worker._audit_loop_wire(
|
||||
task, "failed", {"stdout": ""}, "boom")
|
||||
self.assertEqual(decision, "error")
|
||||
self.assertEqual(final, "failed")
|
||||
row = _row(self.db_path, "TP")
|
||||
self.assertEqual(row[0], "failed")
|
||||
self.assertIsNone(row[1])
|
||||
self.assertIsNone(_state_row(self.db_path, "TP"))
|
||||
self.assertNotIn("worker_audit_loop_pushback", self._actions())
|
||||
|
||||
def test_escalate_store_failure_claims_nothing(self):
|
||||
# I-14②(改动后新行为):升级 UPDATE 失败时接线返回
|
||||
# error、任务保持原终局、状态行不动,且不记“已升级”
|
||||
# 审计——升级审计只在提交后记,绝不谎称已升级。
|
||||
_insert_task(self.db_path, "TE", status="failed", audit_loop=1)
|
||||
_seed_state(self.db_path, "TE", 3, "pushback")
|
||||
worker = self._patched_worker("SET status = 'failed'")
|
||||
task = worker.get_task("TE")
|
||||
final, _, decision = worker._audit_loop_wire(
|
||||
task, "failed", {"stdout": ""}, "boom")
|
||||
self.assertEqual(decision, "error")
|
||||
self.assertEqual(final, "failed")
|
||||
self.assertEqual(_row(self.db_path, "TE")[0], "failed")
|
||||
state = _state_row(self.db_path, "TE")
|
||||
self.assertEqual(state[0], 3)
|
||||
self.assertEqual(state[1], "pushback")
|
||||
self.assertNotIn("worker_audit_loop_escalated", self._actions())
|
||||
|
||||
def test_escalate_cas_miss_audits_skipped(self):
|
||||
# N-2:升级 CAS 未命中(状态已被改动)时记
|
||||
# escalate_skipped 审计、不谎称已升级,任务与状态原样不动。
|
||||
_insert_task(self.db_path, "TC", status="failed", audit_loop=1)
|
||||
_seed_state(self.db_path, "TC", 3, "pushback")
|
||||
_set_status(self.db_path, "TC", "queued")
|
||||
worker = self._worker()
|
||||
task = worker.get_task("TC")
|
||||
final, _, decision = worker._audit_loop_wire(
|
||||
task, "failed", {"stdout": ""}, "boom")
|
||||
self.assertEqual(decision, "error")
|
||||
self.assertEqual(final, "failed")
|
||||
self.assertEqual(_row(self.db_path, "TC")[0], "queued")
|
||||
self.assertEqual(_state_row(self.db_path, "TC")[0], 3)
|
||||
self.assertIn("worker_audit_loop_escalate_skipped",
|
||||
self._actions())
|
||||
self.assertNotIn("worker_audit_loop_escalated", self._actions())
|
||||
|
||||
|
||||
class CoordClearTestCase(_WorkerCase):
|
||||
def test_clear_packets_failure_is_best_effort(self):
|
||||
# I-14③:清包失败只记警告、不炸调用方、不改已定结局。
|
||||
_insert_task(self.db_path, "PP", status="failed", audit_loop=1,
|
||||
packet='{"round": 1}')
|
||||
_insert_task(self.db_path, "CC", status="failed", audit_loop=0,
|
||||
packet='{"round": 1}')
|
||||
worker = self._worker()
|
||||
worker._coord_clear_packets(
|
||||
{"id": "PP", "project_id": "p1"}, ["CC"])
|
||||
self.assertIsNone(_row(self.db_path, "PP")[1])
|
||||
self.assertIsNone(_row(self.db_path, "CC")[1])
|
||||
|
||||
def _boom(path):
|
||||
raise RuntimeError("injected connect failure")
|
||||
|
||||
with mock.patch.object(worker_module, "_db_connect", _boom):
|
||||
# 不抛错即通过:清失败绝不改变已定的父任务结局。
|
||||
worker._coord_clear_packets(
|
||||
{"id": "PP", "project_id": "p1"}, ["CC"])
|
||||
|
||||
|
||||
class ClassifyAndSplitTestCase(_WorkerCase):
|
||||
def test_merge_failed_is_gate_rejected(self):
|
||||
# N-3:merge-failed 归 gate_rejected,不再是
|
||||
# excluded/non_terminal;容忍声明仍优先。
|
||||
worker = self._worker()
|
||||
kind, reason = worker._coord_classify_child(
|
||||
None, {"id": "C1", "status": "merge-failed"}, set())
|
||||
self.assertEqual((kind, reason), ("gate_rejected", "merge_failed"))
|
||||
kind, _ = worker._coord_classify_child(
|
||||
{"on_failure": "tolerate"},
|
||||
{"id": "C2", "status": "merge-failed"}, set())
|
||||
self.assertEqual(kind, "excluded")
|
||||
kind, _ = worker._coord_classify_child(
|
||||
None, {"id": "C3", "status": "merged"}, set())
|
||||
self.assertEqual(kind, "completed")
|
||||
|
||||
def test_wire_splits_subjective_before_evaluate(self):
|
||||
# N-4:单任务接线先拆分再评估——确定性照常推回,主观
|
||||
# 只记审计,不吞推回。
|
||||
_insert_task(self.db_path, "TS", status="failed", audit_loop=1)
|
||||
worker = self._worker()
|
||||
det = Finding("execution_failed", "high", "boom", "re-run",
|
||||
"task completes", kind="deterministic",
|
||||
gate="execution")
|
||||
subj = Finding("design_note", "low", "looks odd", "review",
|
||||
"human ok", kind="subjective", gate=None)
|
||||
worker._audit_loop_gate_results = (
|
||||
lambda task, final, result, gates_checked=None: (
|
||||
{"execution": False}, [det, subj]))
|
||||
task = worker.get_task("TS")
|
||||
final, _, decision = worker._audit_loop_wire(
|
||||
task, "failed", {"stdout": ""}, "boom")
|
||||
self.assertEqual(decision, "pushback")
|
||||
self.assertEqual(final, "queued")
|
||||
needs = [d for _, a, d in self.audits
|
||||
if a == "worker_audit_loop_needs_review"]
|
||||
self.assertTrue(any("design_note" in d for d in needs))
|
||||
|
||||
|
||||
class TerminalSqlTestCase(unittest.TestCase):
|
||||
def test_terminal_sql_matches_old_literal_set(self):
|
||||
# N-6:生成的终态串集合与旧手写字面量逐字等价。
|
||||
from cloud.worker import _terminal_status_sql
|
||||
sql = _terminal_status_sql()
|
||||
inner = sql.strip()[1:-1]
|
||||
got = {part.strip().strip("'") for part in inner.split(",")}
|
||||
self.assertEqual(
|
||||
got,
|
||||
{"completed", "failed", "interrupted", "merged",
|
||||
"merge-failed"})
|
||||
|
||||
|
||||
class GoalWireTestCase(_WorkerCase):
|
||||
def test_goal_pushback_leaves_goal_columns_and_bounds_rounds(self):
|
||||
# N-12 定性:推回不改 goal 列;包贴进内存 goal 与 prompt;
|
||||
# 审计轮次 3 轮后必升级(无无界循环)。
|
||||
_insert_task(self.db_path, "TG", status="failed", audit_loop=1,
|
||||
goal_mode=1, goal="reach the goal",
|
||||
verify_command="true", goal_max_rounds=3)
|
||||
worker = self._worker()
|
||||
task = worker.get_task("TG")
|
||||
final, _, decision = worker._audit_loop_wire(
|
||||
task, "failed", {"goal_met": False, "goal_error": "nope"},
|
||||
"goal unmet")
|
||||
self.assertEqual(decision, "pushback")
|
||||
self.assertEqual(final, "queued")
|
||||
row = _row(self.db_path, "TG")
|
||||
self.assertEqual(row[2], "reach the goal")
|
||||
self.assertEqual(row[3], "true")
|
||||
self.assertEqual(row[4], 3)
|
||||
packet = row[1]
|
||||
self.assertIsNotNone(packet)
|
||||
injected = worker._apply_audit_loop_packet({
|
||||
"id": "TG", "project_id": "p1", "prompt": "do work",
|
||||
"goal": "reach the goal", "goal_mode": 1,
|
||||
"audit_loop_packet": packet})
|
||||
self.assertTrue(injected["prompt"].startswith("[审计推回包"))
|
||||
self.assertTrue(injected["goal"].startswith("[审计推回包"))
|
||||
# 库里的原 prompt/goal 不动。
|
||||
self.assertEqual(_row(self.db_path, "TG")[2], "reach the goal")
|
||||
# 继续推回直至升级:轮次有界。
|
||||
decisions = []
|
||||
for _ in range(3):
|
||||
_set_status(self.db_path, "TG", "failed")
|
||||
task = worker.get_task("TG")
|
||||
_, _, decision = worker._audit_loop_wire(
|
||||
task, "failed",
|
||||
{"goal_met": False, "goal_error": "nope"}, "goal unmet")
|
||||
decisions.append(decision)
|
||||
if decision == "escalate":
|
||||
break
|
||||
self.assertEqual(decisions[-1], "escalate")
|
||||
self.assertEqual(_row(self.db_path, "TG")[0], "failed")
|
||||
self.assertIsNone(_row(self.db_path, "TG")[1])
|
||||
|
||||
|
||||
class ToolApprovalEffectiveTestCase(_WorkerCase):
|
||||
def _insert_approval_task(self, task_id, approvals):
|
||||
_insert_task(self.db_path, task_id, status="queued")
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET agent_tools = ?,"
|
||||
" agent_tool_approvals = ? WHERE id = ?",
|
||||
(json.dumps(["websearch"]), json.dumps(approvals),
|
||||
task_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_ask_is_audited_as_decline_under_host_deny(self):
|
||||
# I-12:派发时真的调 effective,ask 在行里折算成
|
||||
# decline,ask_honoured 恒为 False。
|
||||
self._insert_approval_task("TA", {"websearch": "ask"})
|
||||
worker = self._worker()
|
||||
out = worker.run_one()
|
||||
self.assertEqual(out["status"], "completed")
|
||||
details = [d for _, a, d in self.audits
|
||||
if a == "worker_tool_approvals_declared"]
|
||||
self.assertTrue(details)
|
||||
self.assertIn('"websearch"', details[0])
|
||||
self.assertIn('"declared": "ask"', details[0])
|
||||
self.assertIn('"effective": "decline"', details[0])
|
||||
self.assertIn('"ask_honoured": false', details[0])
|
||||
|
||||
def test_auto_accepts_only_when_host_config_is_auto(self):
|
||||
# host approval 取自传给 host 的 --config 文件:auto 时
|
||||
# auto 声明 effective 为 accept;默认 deny 时为 decline。
|
||||
self._insert_approval_task("TB", {"websearch": "auto"})
|
||||
cfg = os.path.join(self.tmp.name, "host-config.json")
|
||||
with open(cfg, "w", encoding="utf-8") as fh:
|
||||
json.dump({"approval": "auto"}, fh)
|
||||
worker = self._worker(config_path=cfg)
|
||||
out = worker.run_one()
|
||||
self.assertEqual(out["status"], "completed")
|
||||
details = [d for _, a, d in self.audits
|
||||
if a == "worker_tool_approvals_declared"]
|
||||
self.assertIn('"effective": "accept"', details[0])
|
||||
|
||||
|
||||
class GatewayExtractTestCase(_WorkerCase):
|
||||
def _policy(self, capable):
|
||||
return {"mode": "active",
|
||||
"tiers": {"default": {
|
||||
"model": "m1",
|
||||
"requires": {"tool_calling": True}}},
|
||||
"model_capabilities": {"m1": {"tool_calling": capable}}}
|
||||
|
||||
def test_blocked_and_pass_through(self):
|
||||
# I-9①:抽取后行为逐字不变——被拦时早返回 dict +
|
||||
# 门审计 + 标记点;满足时返回 None 继续派发。
|
||||
_insert_task(self.db_path, "TG1", status="queued")
|
||||
worker = self._worker(routing_policy=self._policy(False))
|
||||
task = {"id": "TG1", "project_id": "p1", "model": "m1"}
|
||||
blocked = worker._gateway_capability_check(
|
||||
task, "sub2api", False, time.time())
|
||||
self.assertIsNotNone(blocked)
|
||||
self.assertEqual(blocked["status"], "failed")
|
||||
self.assertIn("worker_capability_gate_rejected", self._actions())
|
||||
self.assertIn("capability", task.get("_gates_checked", []))
|
||||
|
||||
self.audits.clear()
|
||||
_insert_task(self.db_path, "TG2", status="queued")
|
||||
worker2 = self._worker(routing_policy=self._policy(True))
|
||||
task2 = {"id": "TG2", "project_id": "p1", "model": "m1"}
|
||||
self.assertIsNone(worker2._gateway_capability_check(
|
||||
task2, "sub2api", False, time.time()))
|
||||
self.assertNotIn("worker_capability_gate_rejected",
|
||||
self._actions())
|
||||
|
||||
|
||||
class PersistExtractTestCase(_WorkerCase):
|
||||
def test_writeback_only_for_applied_policy(self):
|
||||
# I-9②:只持久化 source=policy 且 applied 的决策。
|
||||
_insert_task(self.db_path, "TW", status="queued", model=None)
|
||||
worker = self._worker()
|
||||
task = {"id": "TW", "project_id": "p1"}
|
||||
worker._persist_policy_model(
|
||||
task, SimpleNamespace(chosen_model="m9", source="policy",
|
||||
applied=True, tier="default"))
|
||||
self.assertEqual(_row(self.db_path, "TW")[5], "m9")
|
||||
self.assertIn("worker_model_persisted", self._actions())
|
||||
self.audits.clear()
|
||||
_insert_task(self.db_path, "TW2", status="queued", model=None)
|
||||
worker._persist_policy_model(
|
||||
{"id": "TW2", "project_id": "p1"},
|
||||
SimpleNamespace(chosen_model="m9", source="shadow",
|
||||
applied=False, tier="default"))
|
||||
self.assertIsNone(_row(self.db_path, "TW2")[5])
|
||||
self.assertNotIn("worker_model_persisted", self._actions())
|
||||
|
||||
|
||||
class ServerHelperTestCase(unittest.TestCase):
|
||||
def test_agent_definition_and_audit_loop_helpers(self):
|
||||
# I-9③:两个小函数与原地行为一致(错误文案逐字)。
|
||||
resolve = CloudControlPlaneHandler._resolve_create_agent_definition
|
||||
_, err = resolve({"agent": {"a": 1}, "agent_name": "x"})
|
||||
self.assertEqual(err, "agent and agent_name cannot both be used")
|
||||
_, err = resolve({"agent": "not-a-dict"})
|
||||
self.assertEqual(err, "agent must be an object")
|
||||
validate = CloudControlPlaneHandler._validate_create_audit_loop
|
||||
self.assertEqual(validate({}), (0, None))
|
||||
self.assertEqual(validate({"audit_loop": True}), (1, None))
|
||||
self.assertEqual(validate({"audit_loop": 1}), (1, None))
|
||||
_, err = validate({"audit_loop": 2})
|
||||
self.assertEqual(err, "audit_loop must be true or 1")
|
||||
|
||||
|
||||
class ParseToleranceTestCase(unittest.TestCase):
|
||||
def test_non_string_items_survive_and_are_tolerated(self):
|
||||
# N-9(文档选项):解析层只保 list 形状,下游容忍非
|
||||
# 字符串项——不过滤、不改旧脏行序列化。
|
||||
self.assertEqual(parse_agent_tools_column('["a", 1]'),
|
||||
["a", 1])
|
||||
self.assertEqual(missing_required_tools(["a", 1], ""),
|
||||
["a", 1])
|
||||
|
||||
|
||||
class DryRunEmptyTestCase(unittest.TestCase):
|
||||
def test_early_returns_share_shape(self):
|
||||
# N-10:两处早期返回经 _empty_report 归一,键集合一致。
|
||||
a = dry_run_policy_document("not-a-dict")
|
||||
b = dry_run_policy_document({"mode": "bogus"})
|
||||
self.assertFalse(a["valid"])
|
||||
self.assertFalse(b["valid"])
|
||||
self.assertEqual(set(a.keys()), set(b.keys()))
|
||||
|
||||
|
||||
class ShadowEndpointTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
||||
timeout=10)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if key:
|
||||
headers["X-API-Key"] = key
|
||||
conn.request(method, path,
|
||||
json.dumps(body) if body is not None else None,
|
||||
headers)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
conn.close()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
data = None
|
||||
return resp.status, data
|
||||
|
||||
def _shadow(self):
|
||||
status, report = self._req(
|
||||
"GET", "/routing-shadow?project_id=" + self.project["id"],
|
||||
key=self.owner_key)
|
||||
self.assertEqual(status, 200, report)
|
||||
return report
|
||||
|
||||
def test_cost_query_failure_flagged_not_500(self):
|
||||
# N-1 + I-14④:成本查询失败时报告仍 200、成本按空算、
|
||||
# cost_unavailable 标明,不抛到 handler 层。
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute("DROP TABLE IF EXISTS cost_records")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
class _BoomCostStore:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise RuntimeError("injected cost store failure")
|
||||
|
||||
with mock.patch.object(server_module, "CostStore",
|
||||
_BoomCostStore):
|
||||
report = self._shadow()
|
||||
self.assertTrue(report["cost_unavailable"])
|
||||
|
||||
def test_sample_window_and_normal_cost_flag(self):
|
||||
# N-5:截断样本(最早 10000 行)附带最早/最晚时间窗;
|
||||
# 正常路径 cost_unavailable 为 False。
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
for task_id, created in (("t1", 10.0), ("t2", 20.0)):
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log(user_id, project_id, action,"
|
||||
" detail, created_at) VALUES(?, ?, ?, ?, ?)",
|
||||
("system", self.project["id"], "worker_routing",
|
||||
"%s tier=plan source=shadow applied=False"
|
||||
" suggested=mx" % task_id, created))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
report = self._shadow()
|
||||
self.assertFalse(report["cost_unavailable"])
|
||||
self.assertEqual(report["sample_window"],
|
||||
{"earliest_created_at": 10.0,
|
||||
"latest_created_at": 20.0})
|
||||
self.assertFalse(report["truncated"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -232,9 +232,13 @@ class WireTestCase(unittest.TestCase):
|
||||
def test_needs_review_leaves_outcome_untouched(self):
|
||||
_insert_task(self.db_path, "T6", status="failed", audit_loop=1)
|
||||
worker = self._worker()
|
||||
worker.audit_loop_evaluate = lambda t, g, findings=None, fixed=None: {
|
||||
# 审计批 5:接线改走 _audit_loop_evaluate_on_conn(同连接
|
||||
# 单事务),桩点随之移到新内核;意图不变——防御分支仍在,
|
||||
# needs_review 时终局、包列一律不动、只记审计。
|
||||
worker._audit_loop_evaluate_on_conn = (
|
||||
lambda conn, t, g, findings=None, fixed=None: {
|
||||
"enabled": True, "decision": "needs_review", "packet": None,
|
||||
"new_state": None, "reason": "subjective"}
|
||||
"new_state": None, "reason": "subjective"})
|
||||
task = worker.get_task("T6")
|
||||
final, _, decision = worker._audit_loop_wire(
|
||||
task, "failed", {"stdout": ""}, "boom")
|
||||
|
||||
+519
-267
@@ -135,6 +135,7 @@ import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cloud.agent_def import AgentDefinition as _AgentDefinition
|
||||
from cloud.agent_def import effective_tool_approvals as _effective_tool_approvals
|
||||
from cloud.agent_def import format_instructions_header as _agent_header
|
||||
from cloud.agent_def import missing_required_tools as _missing_tools
|
||||
from cloud.agent_def import parse_agent_tool_approvals_column as _parse_tool_approvals_col
|
||||
@@ -227,6 +228,20 @@ from cloud.workers import heartbeat_worker, register_worker
|
||||
# 与 server.TERMINAL_STATUSES 对齐:这些状态的任务不再被认领或取消。
|
||||
TERMINAL_STATUSES = frozenset(
|
||||
{"completed", "failed", "interrupted", "merged", "merge-failed"})
|
||||
|
||||
|
||||
def _terminal_status_sql():
|
||||
"""终态集合的 SQL IN 串(审计批 5,N-6)。
|
||||
|
||||
worker 内多处“不在终态才改状态”的 SQL 原先各自手写同一串
|
||||
字面量,改一处漏一处就会口径分裂;现在统一从
|
||||
TERMINAL_STATUSES 生成。排序只为输出稳定,SQL 的 IN 是
|
||||
集合语义,顺序不影响结果;测试断言生成串解析出的集合与
|
||||
旧字面量集合逐字等价。其它模块(server、coordinator)各
|
||||
自的 TERMINAL_STATUSES 常量本批不动,只统一 worker 内 SQL。
|
||||
"""
|
||||
return "(%s)" % ", ".join(
|
||||
"'%s'" % status for status in sorted(TERMINAL_STATUSES))
|
||||
COMPLETED_STATUSES = frozenset({"completed", "merged"})
|
||||
DEFAULT_ENV_KEY = "XCM_API_KEY"
|
||||
# 默认模型(2026-10-27 起为 gpt-5.6-terra):此前默认的
|
||||
@@ -962,8 +977,9 @@ class Worker:
|
||||
(已被别的 worker 抢走)时,只清掉本次新建的占位,绝
|
||||
不动已在跑任务的条目。占位条目在执行路径的 finally
|
||||
里清(本地 _execute_via_process / Docker 同步路径),
|
||||
认领后提前返回的三道门(缺 key / 仓库 / 配额)在
|
||||
run_one 里显式清占位;CAS 语义本身一句未改。
|
||||
认领后提前返回的门(缺 key、仓库、配额、goal 轮次上限、
|
||||
网关准备、网关能力、路由能力、工具审批、必需工具、
|
||||
写回复核等)在 run_one 里显式清占位;CAS 语义本身一句未改。
|
||||
P1 签名门:占位之前先比对多文件签名,签名缺失或已变
|
||||
时直接返回 False(fail-closed,不抢任务、不占槽),
|
||||
并记日志;不要只在 run_loop 轮次边界查——并行池线程
|
||||
@@ -1868,11 +1884,55 @@ class Worker:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _audit_loop_evaluate_on_conn(self, conn, task, gate_results,
|
||||
findings=None, fixed=None):
|
||||
"""在调用方给定的连接上跑评估并写状态(不提交、不关连接)。
|
||||
|
||||
审计批 5(事务分离):本方法是 audit_loop_evaluate 与
|
||||
_audit_loop_wire 共用的内核——前者自带连接、写完提交,
|
||||
后者用同一连接把“状态保存、包写、终局更新”一次提交,
|
||||
不再出现“状态已提交为 escalate、终局更新却没落库”的
|
||||
自相矛盾。返回的决策 dict 与引擎原样一致(packet /
|
||||
new_state 仍为对象,未转 dict);失败类决策另带内部键
|
||||
“_error”(损坏/评估失败的错误文本),调用方在提交后
|
||||
用它写升级审计,公开返回前必须 strip 掉(见
|
||||
audit_loop_evaluate)。评估域内的异常一律收敛为
|
||||
evaluate_failed 升级决策(与旧 audit_loop_evaluate 的
|
||||
except 分支同款),不向调用方抛;连接级异常(提交失败
|
||||
等)仍由调用方处理。
|
||||
"""
|
||||
task_id = task.get("id")
|
||||
if not task.get("audit_loop"):
|
||||
return {"enabled": False, "decision": "off", "packet": None,
|
||||
"new_state": None, "reason": None}
|
||||
try:
|
||||
_ensure_audit_loop(conn)
|
||||
state, corrupted, error = _audit_loop_load(conn, task_id)
|
||||
if corrupted:
|
||||
return {"enabled": True, "decision": "escalate",
|
||||
"packet": None, "new_state": None,
|
||||
"reason": "count_corrupted", "_error": error}
|
||||
decision = _audit_loop_evaluate(
|
||||
state, task_id, gate_results, findings=findings,
|
||||
fixed=fixed)
|
||||
if decision["new_state"] is not None:
|
||||
_audit_loop_save(conn, decision["new_state"])
|
||||
return decision
|
||||
except Exception as exc:
|
||||
# 评估或写状态失败:轮次不可信,fail-closed 到升级。
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return {"enabled": True, "decision": "escalate",
|
||||
"packet": None, "new_state": None,
|
||||
"reason": "evaluate_failed", "_error": str(exc)}
|
||||
|
||||
def audit_loop_evaluate(self, task, gate_results, findings=None,
|
||||
fixed=None):
|
||||
"""有界审计循环的显式入口(默认关闭,绝不由 run_one 自动调用)。
|
||||
|
||||
启用条件二选一中只选其一:任务字典含 audit_loop 真值。
|
||||
启用条件只有一个:任务字典含 audit_loop 真值。
|
||||
没有 env 开关——全局 env 会让所有任务悄悄启用循环,本方法
|
||||
刻意拒绝它。开关关闭时返回 enabled=False 且不碰数据库,
|
||||
run_one、auto_resume、协调员、goal 断点行为逐字节不变。
|
||||
@@ -1889,6 +1949,11 @@ class Worker:
|
||||
fail-closed 到 escalate,不进循环。
|
||||
gate_results 必须含本轮全部已跑的门;上一轮通过的门本轮
|
||||
缺跑或变失败时,引擎按 gate_missing / regression 升级或推回。
|
||||
调用方契约(审计批 5,N-4):主观发现绝不直接进本方法——
|
||||
调用方须先用 _coord_split_findings 拆分,确定性进循环,
|
||||
主观只记审计;协调员合流与单任务接线均已如此。
|
||||
内部实现走 _audit_loop_evaluate_on_conn(自带连接、写完
|
||||
提交),与单任务接线共用同一内核。
|
||||
"""
|
||||
task = task if isinstance(task, dict) else {}
|
||||
if not task.get("audit_loop"):
|
||||
@@ -1909,45 +1974,41 @@ class Worker:
|
||||
return {"enabled": True, "decision": "escalate", "packet": None,
|
||||
"new_state": None, "reason": "state_unreadable"}
|
||||
try:
|
||||
_ensure_audit_loop(conn)
|
||||
state, corrupted, error = _audit_loop_load(conn, task_id)
|
||||
if corrupted:
|
||||
try:
|
||||
self._audit(
|
||||
project_id, "worker_audit_loop_escalated",
|
||||
"%s reason=count_corrupted error=%s"
|
||||
% (task_id, error))
|
||||
except Exception:
|
||||
pass
|
||||
return {"enabled": True, "decision": "escalate",
|
||||
"packet": None, "new_state": None,
|
||||
"reason": "count_corrupted"}
|
||||
decision = _audit_loop_evaluate(
|
||||
state, task_id, gate_results, findings=findings,
|
||||
fixed=fixed)
|
||||
decision = self._audit_loop_evaluate_on_conn(
|
||||
conn, task, gate_results, findings=findings, fixed=fixed)
|
||||
if decision["new_state"] is not None:
|
||||
_audit_loop_save(conn, decision["new_state"])
|
||||
try:
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
# 评估或写状态失败:轮次不可信,fail-closed 到升级。
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._audit(project_id, "worker_audit_loop_escalated",
|
||||
"%s reason=evaluate_failed error=%s"
|
||||
% (task_id, exc))
|
||||
except Exception:
|
||||
pass
|
||||
return {"enabled": True, "decision": "escalate", "packet": None,
|
||||
"new_state": None, "reason": "evaluate_failed"}
|
||||
decision = {"enabled": True, "decision": "escalate",
|
||||
"packet": None, "new_state": None,
|
||||
"reason": "evaluate_failed",
|
||||
"_error": str(exc)}
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if decision["decision"] == "escalate":
|
||||
if decision["reason"] == "count_corrupted":
|
||||
try:
|
||||
self._audit(
|
||||
project_id, "worker_audit_loop_escalated",
|
||||
"%s reason=count_corrupted error=%s"
|
||||
% (task_id, decision.get("_error")))
|
||||
except Exception:
|
||||
pass
|
||||
elif decision["reason"] == "evaluate_failed":
|
||||
try:
|
||||
self._audit(project_id, "worker_audit_loop_escalated",
|
||||
"%s reason=evaluate_failed error=%s"
|
||||
% (task_id, decision.get("_error")))
|
||||
except Exception:
|
||||
pass
|
||||
elif decision["decision"] == "escalate":
|
||||
try:
|
||||
self._audit(
|
||||
project_id, "worker_audit_loop_escalated",
|
||||
@@ -1955,7 +2016,8 @@ class Worker:
|
||||
% (task_id, decision["reason"], decision["round"]))
|
||||
except Exception:
|
||||
pass
|
||||
result = dict(decision)
|
||||
result = {k: v for k, v in decision.items()
|
||||
if not k.startswith("_")}
|
||||
result["enabled"] = True
|
||||
if result.get("packet") is not None:
|
||||
result["packet"] = result["packet"].to_dict()
|
||||
@@ -2054,14 +2116,48 @@ class Worker:
|
||||
- escalate:清掉推回包列,状态置 failed(合法终态;
|
||||
VALID_STATUSES 里没有 needs_review,绝不发明它),
|
||||
error_text 写明“审计循环升级”+ 轮次 + 原因 + 上一轮
|
||||
错误摘要,之后不再自动重排。引擎已记
|
||||
worker_audit_loop_escalated 审计,这里不重复记。
|
||||
- needs_review:防御分支(接线从不产生主观发现,正常
|
||||
流程到不了这里):只记 worker_audit_loop_needs_review
|
||||
错误摘要,之后不再自动重排。升级审计在事务提交后记,
|
||||
与旧接线“引擎已记、这里不重复记”的审计行一模一样,
|
||||
只是时序从“评估提交后”挪到“接线提交后”。
|
||||
- needs_review:防御分支(接线只产生确定性发现,正常
|
||||
流程到不了这里;审计批 5(N-4)起接线先拆分主观发现
|
||||
只记审计再评确定性,所以 needs_review 更到不了):
|
||||
只记 worker_audit_loop_needs_review
|
||||
审计,终局、包列一律不动,主观审计永不自动循环。
|
||||
- off / skipped:未启用或协调员父任务,不碰库。
|
||||
包列写失败、CAS 失败时 fail-closed 回原终局并记警告,
|
||||
绝不谎称已推回或已升级。
|
||||
审计批 5(事务分离 + N-2):状态保存、包写、终局更新在
|
||||
同一连接、同一事务里一次提交——评估内核走
|
||||
_audit_loop_evaluate_on_conn(只写不提交),分支写完
|
||||
才 commit;事务内任何一步失败(包括升级/推回的 CAS
|
||||
未命中)即整体回滚,任务不以“已升级状态 + completed
|
||||
任务行”的矛盾状态落地。pass 的清包与 escalate 的置
|
||||
failed 同样带 AND status=<终局> CAS(旧版 pass 清包与
|
||||
escalate 更新无 CAS);CAS 未命中时记警告/审计(升级
|
||||
未命中另记 worker_audit_loop_escalate_skipped 审计),
|
||||
fail-closed 回原终局,绝不谎称已推回或已升级。
|
||||
例外:接线连库都连不上时直接返回 error、任务保持原
|
||||
终局(旧版评估连库失败会返回 state_unreadable 升级、
|
||||
接线另开连接置 failed;单事务下无连接即无决策可提交,
|
||||
不谎称升级)。升级成功后结果文本写回(_write_execution_
|
||||
result,自带连接)仍是提交后的独立写:它失败时只记
|
||||
警告、不再把已 failed 的任务谎报成 error(旧版写回
|
||||
失败会返回 error,尽管状态已是 failed)。
|
||||
goal 任务(审计批 5,N-12 定性,只文档化、不改行为):
|
||||
推回只改 tasks.status 与包列(+ 轮次状态表),不碰
|
||||
goal / verify_command / goal_max_rounds 列,也不碰宿主
|
||||
goal 状态文件——goal_rounds_done 根本不在任务表里,
|
||||
接线既无权重置它、也不由接线延续它。下一轮正常认领
|
||||
时包前言贴进内存 prompt 与 goal(_apply_audit_loop_
|
||||
packet 对 goal 任务同步贴两者),但 goal 任务下一轮
|
||||
实际会撞宿主任务名已存在、经 run_one 的已存在回落走
|
||||
断点续跑(goal-resume 不传 --goal-text,宿主用存储的
|
||||
goal 与 goal_rounds_done+1 继续),所以包前言贴了却
|
||||
不进续跑循环——这是审计推回对 goal 任务的已知效果
|
||||
缺口,不是轮次会计错误,本批不改行为、留待后续批
|
||||
定夺。轮次会计本身有界:审计轮次硬上限 3(MAX_
|
||||
PUSHBACKS),3 轮后必升级;goal 旧形态总回合数受
|
||||
goal_max_rounds 约束(续跑不重置计数),新形态受配额
|
||||
约束;两组计数器独立、不叠加成无界循环。
|
||||
"""
|
||||
task_id = task.get("id")
|
||||
project_id = task.get("project_id")
|
||||
@@ -2075,18 +2171,29 @@ class Worker:
|
||||
# 不再硬编码成通过。未传(旧直接调用)时保持
|
||||
# 旧行为,见 _audit_loop_gate_results 文档。
|
||||
task, final, result, task.get("_gates_checked"))
|
||||
# 审计批 5(N-4):先拆分再评估,与协调员合流对称。
|
||||
# 单任务接线目前只产生确定性发现,拆分在生产路径上
|
||||
# 不改变决策;主观发现只记审计,不进循环、不吞推回。
|
||||
deterministic, subjective = self._coord_split_findings(findings)
|
||||
if subjective:
|
||||
self._audit(project_id, "worker_audit_loop_needs_review",
|
||||
"%s subjective=%s" % (
|
||||
task_id,
|
||||
",".join(f.id for f in subjective)))
|
||||
try:
|
||||
decision = self.audit_loop_evaluate(
|
||||
task, gate_results, findings=findings)
|
||||
conn = _db_connect(self.db_path)
|
||||
except Exception as exc:
|
||||
try:
|
||||
log_event(self.logger, "worker_audit_loop_wire_failed",
|
||||
task_id=task_id, project_id=project_id,
|
||||
status="warning",
|
||||
message="audit loop evaluate failed: %s" % exc)
|
||||
message="audit loop wire connect failed: %s" % exc)
|
||||
except Exception:
|
||||
pass
|
||||
return final, error, "error"
|
||||
try:
|
||||
decision = self._audit_loop_evaluate_on_conn(
|
||||
conn, task, gate_results, findings=deterministic)
|
||||
kind = decision.get("decision")
|
||||
if kind == "off":
|
||||
return final, error, "off"
|
||||
@@ -2095,57 +2202,51 @@ class Worker:
|
||||
"%s" % task_id)
|
||||
return final, error, "needs_review"
|
||||
if kind == "pass":
|
||||
try:
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
self._ensure_result_columns(conn)
|
||||
conn.execute(
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET audit_loop_packet = NULL,"
|
||||
" updated_at = ? WHERE id = ?",
|
||||
(time.time(), task_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
" updated_at = ? WHERE id = ? AND status = ?",
|
||||
(time.time(), task_id, final))
|
||||
if cur.rowcount != 1:
|
||||
# 审计批 5(N-2):清包同样带终局 CAS——状态
|
||||
# 已被改动时不清包(可能已是新一轮的包),
|
||||
# 整个事务回滚(状态保存亦不落库),不谎称 pass。
|
||||
conn.rollback()
|
||||
try:
|
||||
log_event(self.logger,
|
||||
"worker_audit_loop_packet_clear_failed",
|
||||
log_event(
|
||||
self.logger,
|
||||
"worker_audit_loop_packet_clear_skipped",
|
||||
task_id=task_id, project_id=project_id,
|
||||
status="warning",
|
||||
message="clear packet failed: %s" % exc)
|
||||
message="task status changed before packet clear")
|
||||
except Exception:
|
||||
pass
|
||||
return final, error, "error"
|
||||
conn.commit()
|
||||
self._audit(project_id, "worker_audit_loop_passed",
|
||||
"%s round=%s" % (task_id, decision.get("round")))
|
||||
return final, error, "pass"
|
||||
if kind == "pushback":
|
||||
packet = decision.get("packet") or {}
|
||||
try:
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
packet = decision.get("packet")
|
||||
if packet is None:
|
||||
packet_dict = {}
|
||||
elif hasattr(packet, "to_dict"):
|
||||
packet_dict = packet.to_dict()
|
||||
else:
|
||||
packet_dict = packet
|
||||
self._ensure_result_columns(conn)
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'queued',"
|
||||
" audit_loop_packet = ?, updated_at = ?"
|
||||
" WHERE id = ? AND status = ?",
|
||||
(json.dumps(packet), time.time(), task_id, final))
|
||||
conn.commit()
|
||||
moved = cur.rowcount == 1
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
(json.dumps(packet_dict), time.time(), task_id, final))
|
||||
if cur.rowcount != 1:
|
||||
# CAS 未命中即整个事务回滚:状态保存亦不落库,
|
||||
# 不出现“状态已推回、任务仍是终局”的半截态。
|
||||
conn.rollback()
|
||||
try:
|
||||
log_event(self.logger,
|
||||
"worker_audit_loop_pushback_failed",
|
||||
task_id=task_id, project_id=project_id,
|
||||
status="warning",
|
||||
message="pushback store failed: %s" % exc)
|
||||
except Exception:
|
||||
pass
|
||||
return final, error, "error"
|
||||
if not moved:
|
||||
try:
|
||||
log_event(self.logger,
|
||||
log_event(
|
||||
self.logger,
|
||||
"worker_audit_loop_pushback_skipped",
|
||||
task_id=task_id, project_id=project_id,
|
||||
status="warning",
|
||||
@@ -2153,46 +2254,101 @@ class Worker:
|
||||
except Exception:
|
||||
pass
|
||||
return final, error, "error"
|
||||
conn.commit()
|
||||
self._audit(project_id, "worker_audit_loop_pushback",
|
||||
"%s round=%s findings=%s" % (
|
||||
task_id, decision.get("round"),
|
||||
",".join(
|
||||
f["id"]
|
||||
for f in packet.get("findings", []))))
|
||||
for f in packet_dict.get("findings", []))))
|
||||
return "queued", error, "pushback"
|
||||
if kind == "escalate":
|
||||
esc_error = (
|
||||
"审计循环升级(第 %s 轮,原因 %s):%s"
|
||||
% (decision.get("round"), decision.get("reason"),
|
||||
(error or "未通过确定性门")[:500]))
|
||||
try:
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
self._ensure_result_columns(conn)
|
||||
conn.execute(
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'failed',"
|
||||
" audit_loop_packet = NULL, updated_at = ?"
|
||||
" WHERE id = ?", (time.time(), task_id))
|
||||
" WHERE id = ? AND status = ?",
|
||||
(time.time(), task_id, final))
|
||||
if cur.rowcount != 1:
|
||||
# 审计批 5(N-2):升级同样带终局 CAS;未命中
|
||||
# 时记审计、不谎称已升级,整个事务回滚。
|
||||
conn.rollback()
|
||||
self._audit(
|
||||
project_id, "worker_audit_loop_escalate_skipped",
|
||||
"%s status changed before escalate" % task_id)
|
||||
try:
|
||||
log_event(
|
||||
self.logger,
|
||||
"worker_audit_loop_escalate_skipped",
|
||||
task_id=task_id, project_id=project_id,
|
||||
status="warning",
|
||||
message="task status changed before escalate")
|
||||
except Exception:
|
||||
pass
|
||||
return final, error, "error"
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
# 升级审计在提交后记(与旧接线“引擎已记”的行同款,
|
||||
# 只是时序从评估提交后挪到接线提交后,绝不出现
|
||||
# “审计写着已升级、任务行却仍是终局”的矛盾)。
|
||||
if decision.get("reason") == "count_corrupted":
|
||||
self._audit(
|
||||
project_id, "worker_audit_loop_escalated",
|
||||
"%s reason=count_corrupted error=%s"
|
||||
% (task_id, decision.get("_error")))
|
||||
elif decision.get("reason") == "evaluate_failed":
|
||||
self._audit(
|
||||
project_id, "worker_audit_loop_escalated",
|
||||
"%s reason=evaluate_failed error=%s"
|
||||
% (task_id, decision.get("_error")))
|
||||
else:
|
||||
self._audit(
|
||||
project_id, "worker_audit_loop_escalated",
|
||||
"%s reason=%s round=%s"
|
||||
% (task_id, decision.get("reason"),
|
||||
decision.get("round")))
|
||||
try:
|
||||
self._write_execution_result(
|
||||
task_id, result_text=(result or {}).get("stdout", ""),
|
||||
error_text=esc_error, actual_model=None,
|
||||
started_at=None, finished_at=time.time(),
|
||||
only_if_status="failed")
|
||||
except Exception as exc:
|
||||
# 结果文本写回是提交后的独立写:失败只记警告,
|
||||
# 任务已是 failed,不再谎报成 error。
|
||||
try:
|
||||
log_event(self.logger,
|
||||
"worker_audit_loop_escalate_failed",
|
||||
log_event(
|
||||
self.logger,
|
||||
"worker_audit_loop_escalate_result_failed",
|
||||
task_id=task_id, project_id=project_id,
|
||||
status="warning",
|
||||
message="escalate store failed: %s" % exc)
|
||||
message="escalate result write failed: %s" % exc)
|
||||
except Exception:
|
||||
pass
|
||||
return "failed", esc_error, "escalate"
|
||||
conn.rollback()
|
||||
return final, error, "error"
|
||||
except Exception as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
log_event(self.logger, "worker_audit_loop_wire_failed",
|
||||
task_id=task_id, project_id=project_id,
|
||||
status="warning",
|
||||
message="audit loop wire store failed: %s" % exc)
|
||||
except Exception:
|
||||
pass
|
||||
return final, error, "error"
|
||||
return "failed", esc_error, "escalate"
|
||||
return final, error, "error"
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _notify_task_finished(self, task_id, project_id, event_type,
|
||||
error=None):
|
||||
@@ -2940,8 +3096,7 @@ class Worker:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'interrupted', updated_at = ?"
|
||||
" WHERE id = ? AND status NOT IN "
|
||||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||||
" 'merge-failed')",
|
||||
+ _terminal_status_sql(),
|
||||
(time.time(), task_id),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -5947,8 +6102,7 @@ class Worker:
|
||||
"UPDATE tasks SET status = 'interrupted',"
|
||||
" interrupted_reason = 'coordinator_orphan',"
|
||||
" updated_at = ? WHERE id = ? AND status NOT IN "
|
||||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||||
" 'merge-failed')",
|
||||
+ _terminal_status_sql(),
|
||||
(time.time(), ch["id"]))
|
||||
if cur.rowcount == 1:
|
||||
marked.append(ch["id"])
|
||||
@@ -6097,8 +6251,7 @@ class Worker:
|
||||
rows = conn.execute(
|
||||
"SELECT id, parent_task_id, project_id FROM tasks"
|
||||
" WHERE parent_task_id IS NOT NULL AND status NOT IN "
|
||||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||||
" 'merge-failed')").fetchall()
|
||||
+ _terminal_status_sql()).fetchall()
|
||||
parents = {}
|
||||
for row in rows:
|
||||
try:
|
||||
@@ -6129,8 +6282,7 @@ class Worker:
|
||||
"UPDATE tasks SET status = 'interrupted',"
|
||||
" interrupted_reason = 'coordinator_orphan',"
|
||||
" updated_at = ? WHERE id = ? AND status NOT IN "
|
||||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||||
" 'merge-failed')",
|
||||
+ _terminal_status_sql(),
|
||||
(time.time(), cid))
|
||||
if cur.rowcount == 1:
|
||||
marked.append((pid, cid))
|
||||
@@ -6155,6 +6307,10 @@ class Worker:
|
||||
再调 evaluate:确定性进循环,主观只记审计。返回
|
||||
(deterministic, subjective)。本方法本身不产生发现,
|
||||
合流代码产生的发现全是确定性的,拆分是防御层。
|
||||
审计批 5(N-4):单任务接线 _audit_loop_wire 同样先拆分
|
||||
再调 evaluate(对称;单任务接线目前只产生确定性发现,
|
||||
拆分在生产路径上不改变决策,只防未来某处混入主观发现
|
||||
时把确定性推回吞掉)。
|
||||
"""
|
||||
deterministic = []
|
||||
subjective = []
|
||||
@@ -6326,6 +6482,8 @@ class Worker:
|
||||
completed:用户取消等 excluded 情形下,审计 pass
|
||||
只表示“无可重做的确定性失败”,父任务终局仍由
|
||||
overall 定(取消的子任务仍会使 overall 为 failed)。
|
||||
审计批 5(N-3):merge-failed 归 gate_rejected(见
|
||||
分类体内的注释),不再落入 excluded/non_terminal。
|
||||
"""
|
||||
status = (row or {}).get("status")
|
||||
if status == "completed" or status == "merged":
|
||||
@@ -6337,6 +6495,21 @@ class Worker:
|
||||
return "excluded", "user_cancelled"
|
||||
if row.get("interrupted_reason") == "coordinator_orphan":
|
||||
return "excluded", "orphaned"
|
||||
# 审计批 5(N-3):merge-failed 明确归 gate_rejected,
|
||||
# 不再是 excluded/non_terminal。理由:①它是确定性终态——
|
||||
# agent 的工作已做完,卡在合入环节(冲突/目标分支状态),
|
||||
# 重跑 agent 解决不了合入问题,修复方在运营方(手动解
|
||||
# 冲突、手动 /pr 重试——手动重排 SQL 本就另接受
|
||||
# merge-failed,与审计推回是两条路);②审计推回的重排
|
||||
# SQL 只接受 ('failed', 'interrupted'),即使归 execution
|
||||
# 也会被重排步静默跳过(rowcount 0 → pushback_skipped),
|
||||
# 父任务却已回 queued,等于一次无子任务重排的空推回;
|
||||
# 归 gate_rejected 则父任务直接升级,不烧审计轮次。
|
||||
# 位置在门拒绝审计/标记检查之前:merge-failed 本身已是
|
||||
# 足够强的确定性证据,不必再靠审计行或 error 文案佐证;
|
||||
# 容忍/取消/孤儿仍优先(运营方已声明的处置意图更强)。
|
||||
if status == "merge-failed":
|
||||
return "gate_rejected", "merge_failed"
|
||||
child_id = row.get("id")
|
||||
if child_id in (gate_rejected_ids or set()):
|
||||
return "gate_rejected", "gate_audit"
|
||||
@@ -7160,6 +7333,227 @@ class Worker:
|
||||
" required capability"
|
||||
% (tier, model, ", ".join(known)))
|
||||
|
||||
def _gateway_capability_check(self, task, gateway_provider,
|
||||
_explicit_model, started_at):
|
||||
"""网关能力检查门(审计批 5,I-9①抽取,行为逐字不变)。
|
||||
|
||||
原 run_one 内整段平移:判定本身(evaluate_capability)
|
||||
已是纯函数,本方法是 worker 内唯一的一处接线调用——
|
||||
这就是立约的新门形状:纯判定函数 + 一处接线调用;
|
||||
coord_store 抽取是中长期项,本批只在注释里写明不做。
|
||||
返回:被拦时返回 run_one 同款早返回 dict(dispatched
|
||||
True、status failed、error 为门错误文本),否则返回
|
||||
None(调用方继续派发)。副作用点逐项核对(与原代码
|
||||
一致,抽取后无增减):
|
||||
- 审计:显式模型记 worker_capability_gateway_explicit
|
||||
(含 blocked 标志);shadow 档记
|
||||
worker_capability_gateway_shadow;被拦时记
|
||||
worker_capability_gate_rejected(detail 为
|
||||
任务 id 加错误格式)。
|
||||
- 标记:verdict 实际算出时 _mark_gate_checked(task,
|
||||
“capability”)(审计批 4,I-10 标记点,位置不变)。
|
||||
- 状态:被拦时 _set_status(task_id, “failed”)。
|
||||
- 结果写回:被拦时 _write_execution_result(
|
||||
only_if_status 为“failed”)。
|
||||
- 清理:被拦时 _cleanup_task_seed + _running_clear。
|
||||
- 不改 task 的 model,不碰事件列表;无策略、无档需求、
|
||||
档模式 off 时一律不查(旧行为不变)。
|
||||
"""
|
||||
task_id = task.get("id")
|
||||
# Audit fix:网关能力检查——此前路由决策只在模型
|
||||
# 为空时跑,sub2api 网关先填默认模型后决策直接跳
|
||||
# 过,能力门在生产路径上完全不生效。补齐:网关填
|
||||
# 的模型同样按档需求检查,档模式与 v8 一致:shadow
|
||||
# 只审计不拦,active 不满足则 fail-fast(同款工具
|
||||
# 门形状,清占位、不调 runner)。没有策略、没有档
|
||||
# 需求、档模式 off 时一律不查,旧行为不变。注意:
|
||||
# 这里没有 worker 默认可回落(网关模型就是该任务
|
||||
# 的网关内模型,换成 worker 默认会跑错网关),所以
|
||||
# active 下不满足即拦,不存在“先试 worker 默认”
|
||||
# 的中间态;运营方须先把网关模型写进能力目录,
|
||||
# 否则 unknown 同样拦(fail-closed,与 v8 一致)。
|
||||
# 例外:任务级显式模型按 Audit fix 4 分档——已知
|
||||
# 不满足才拦,unknown 只审计(见下),因为显式
|
||||
# 优先保护的是用户选了哪个模型,不是选的模型
|
||||
# 一定能跑;目录陈旧不应惩罚正确的显式选择。
|
||||
# 审计批 4(I-7):显式模型的 known/unknown 分档检查
|
||||
# 与网关无关——网关分支只决定“待查模型是哪一个”
|
||||
# (网关填充模型或显式模型),分档判定走第 3 批统一
|
||||
# 的 evaluate_capability。进入条件因此是“有策略且
|
||||
# (走了 sub2api 网关,或显式任务走了任一真实网关)”:
|
||||
# 非显式 + 非 sub2api 的任务根本不进块(它们模型
|
||||
# 为空、后续走 decide_routing,不存在误拦);显式
|
||||
# + 无网关(provider 为 None)的任务也不进块——它
|
||||
# 根本没走网关,显式语义不变(写回模型仍走后续
|
||||
# 复核,用户显式仍不被复核,见 fixes5 契约);
|
||||
# 显式 + sub2api 路径与原来逐字一致(同一审计
|
||||
# action、同一 error 文案、同一 blocked 标志),
|
||||
# 不会重复审计或重复拦;显式 + 非 sub2api 网关
|
||||
# 在 active 下已知不满足时从“直接派发”变为“拦”,
|
||||
# 这是有意的行为变更。
|
||||
# 附带:已持久化的显式任务在非 sub2api 网关下会
|
||||
# 同时走本门与后续写回复核(本门不拦时复核再按
|
||||
# 分档审计/拦),两者审计 action 不同
|
||||
# (gateway_explicit 与 recheck_*),不是重复拦。
|
||||
if self.routing_policy is not None \
|
||||
and (gateway_provider == "sub2api"
|
||||
or (_explicit_model
|
||||
and gateway_provider is not None)):
|
||||
_gw_tier = classify_task(task)
|
||||
_gw_requires = self.routing_policy.tier_requirements.get(
|
||||
_gw_tier) or {}
|
||||
if _gw_requires:
|
||||
_gw_mode = self.routing_policy.tier_mode(_gw_tier)
|
||||
if _gw_mode in ("active", "shadow"):
|
||||
_gw_verdict = evaluate_capability(
|
||||
self.routing_policy.model_capabilities,
|
||||
task.get("model"), _gw_requires)
|
||||
# 审计批 4(I-10):走到这里说明网关
|
||||
# 能力检查真正算了 verdict。
|
||||
_mark_gate_checked(task, "capability")
|
||||
if not _gw_verdict.ok:
|
||||
_gw_note = ("gateway model %s %s capability:"
|
||||
" %s" % (
|
||||
task.get("model"),
|
||||
"cannot verify"
|
||||
if _gw_verdict.unknown
|
||||
else "lacks",
|
||||
", ".join(_gw_verdict.missing)))
|
||||
# Audit fix 4(第三遍审计分档建议):
|
||||
# 显式模型按 known/unknown 分档。known
|
||||
# 是目录明确写了不满足(布尔 False、
|
||||
# 或 context 低于需求),与网关默认
|
||||
# 模型同款 fail-fast;unknown 是目录
|
||||
# 无该模型、或条目缺字段,目录陈旧
|
||||
# 不应惩罚用户的正确选择,只审计不拦。
|
||||
# shadow 档两种情况都只审计。
|
||||
_gw_known = _gw_verdict.known_missing
|
||||
_gw_block = False
|
||||
_gw_error = None
|
||||
if _explicit_model:
|
||||
_gw_blocked = (
|
||||
_gw_mode == "active" and bool(_gw_known))
|
||||
# 显式检查结果独立审计,含 blocked
|
||||
# 标志:被拦的已知不满足也要进影子
|
||||
# 报告的显式失败清单,否则报告会
|
||||
# 漏掉恰恰最该被看到的一类。
|
||||
self._audit(
|
||||
task["project_id"],
|
||||
"worker_capability_gateway_explicit",
|
||||
"%s model=%s tier=%s mode=%s"
|
||||
" missing=%s unknown=%s blocked=%s"
|
||||
% (task_id, task.get("model"),
|
||||
_gw_tier, _gw_mode,
|
||||
",".join(_gw_verdict.missing) or "-",
|
||||
_gw_verdict.unknown,
|
||||
_gw_blocked))
|
||||
if _gw_blocked:
|
||||
_gw_block = True
|
||||
_gw_error = (
|
||||
"%s tier explicit model %s lacks"
|
||||
" capability: %s; refusing to"
|
||||
" dispatch a model known to lack"
|
||||
" a required capability"
|
||||
% (_gw_tier, task.get("model"),
|
||||
", ".join(_gw_known)))
|
||||
elif _gw_mode == "shadow":
|
||||
self._audit(
|
||||
task["project_id"],
|
||||
"worker_capability_gateway_shadow",
|
||||
"%s tier=%s %s (shadow, not blocked)"
|
||||
% (task_id, _gw_tier, _gw_note))
|
||||
else:
|
||||
_gw_block = True
|
||||
_gw_error = (
|
||||
"%s tier gateway model %s; refusing"
|
||||
" to dispatch an incapable or"
|
||||
" unverifiable model"
|
||||
% (_gw_tier, _gw_note))
|
||||
if _gw_block:
|
||||
self._set_status(task_id, "failed")
|
||||
self._write_execution_result(
|
||||
task_id, error_text=_gw_error,
|
||||
actual_model=self.model,
|
||||
started_at=started_at,
|
||||
finished_at=time.time(),
|
||||
only_if_status="failed")
|
||||
self._audit(
|
||||
task["project_id"],
|
||||
"worker_capability_gate_rejected",
|
||||
"%s: %s" % (task_id, _gw_error))
|
||||
log_event(
|
||||
self.logger,
|
||||
"worker_capability_gate_rejected",
|
||||
task_id=task_id,
|
||||
project_id=task["project_id"],
|
||||
status="failed")
|
||||
# Audit fix 3(M5):网关门拒绝
|
||||
# 早返回也要清暂存,同缺 key 门。
|
||||
self._cleanup_task_seed(task)
|
||||
self._running_clear(task_id)
|
||||
return {"dispatched": True,
|
||||
"task_id": task_id,
|
||||
"host_name":
|
||||
self.executor._host_name(task)
|
||||
if hasattr(self.executor,
|
||||
"_host_name")
|
||||
else task_id,
|
||||
"status": "failed",
|
||||
"executor": self._executor_name(),
|
||||
"stdout": "", "error": _gw_error,
|
||||
"events": [], "cost_record_id": None}
|
||||
return None
|
||||
|
||||
def _persist_policy_model(self, task, decision):
|
||||
"""策略写回(审计批 5,I-9②抽取,行为逐字不变)。
|
||||
|
||||
原 run_one 内 decide_routing 后的写回段平移:只持久化
|
||||
source=policy 且 applied 的决策(shadow / worker_default /
|
||||
worker_explicit 一律不写回,见原注释)。副作用点逐项核对
|
||||
(与原代码一致,抽取后无增减):
|
||||
- 库写:CAS 式 UPDATE tasks SET model(仅当库模型仍为
|
||||
NULL/空时写,绝不覆盖显式模型),自带连接、写完提交。
|
||||
- 日志:写失败时记 worker_model_writeback_failed 警告,
|
||||
不炸派发(本轮内存模型照常生效)。
|
||||
- 审计:仅写成功时记 worker_model_persisted(身份标记,
|
||||
后续尝试据此区分写回模型与用户显式模型)。
|
||||
- 不改任务状态、不清占位、不记 gates_checked 标记、不碰
|
||||
task["model"](内存模型的赋值仍在调用方原地)。
|
||||
新门形状规则同 _gateway_capability_check:纯判定
|
||||
(decide_routing)+ worker 内一处接线调用。
|
||||
"""
|
||||
task_id = task.get("id")
|
||||
_persisted = False
|
||||
if decision.chosen_model and decision.source == "policy" \
|
||||
and decision.applied:
|
||||
try:
|
||||
_wb_conn = _db_connect(self.db_path)
|
||||
try:
|
||||
_wb_conn.execute(
|
||||
"UPDATE tasks SET model = ? WHERE id = ?"
|
||||
" AND (model IS NULL OR model = '')",
|
||||
(decision.chosen_model, task_id))
|
||||
_wb_conn.commit()
|
||||
finally:
|
||||
_wb_conn.close()
|
||||
_persisted = True
|
||||
except Exception as exc:
|
||||
log_event(self.logger, "worker_model_writeback_failed",
|
||||
task_id=task_id,
|
||||
project_id=task["project_id"],
|
||||
status="warning",
|
||||
message="model writeback failed: %s" % exc)
|
||||
if _persisted:
|
||||
# 写回模型的身份标记:后续尝试据此区分
|
||||
# 写回模型与用户显式模型(见
|
||||
# _is_persisted_model)。审计本身 best-effort,
|
||||
# 丢了也不影响写回,旧模型靠 policy 行兜底。
|
||||
self._audit(task["project_id"],
|
||||
"worker_model_persisted",
|
||||
"%s tier=%s model=%s" % (
|
||||
task_id, decision.tier,
|
||||
decision.chosen_model))
|
||||
|
||||
def run_one(self, task=None):
|
||||
"""认领并执行一个任务,返回结果 dict(见模块 docstring)。
|
||||
|
||||
@@ -7541,148 +7935,13 @@ class Worker:
|
||||
# 门可列入 gate_results。
|
||||
_mark_gate_checked(task, "gateway")
|
||||
|
||||
# Audit fix:网关能力检查——此前路由决策只在模型
|
||||
# 为空时跑,sub2api 网关先填默认模型后决策直接跳
|
||||
# 过,能力门在生产路径上完全不生效。补齐:网关填
|
||||
# 的模型同样按档需求检查,档模式与 v8 一致:shadow
|
||||
# 只审计不拦,active 不满足则 fail-fast(同款工具
|
||||
# 门形状,清占位、不调 runner)。没有策略、没有档
|
||||
# 需求、档模式 off 时一律不查,旧行为不变。注意:
|
||||
# 这里没有 worker 默认可回落(网关模型就是该任务
|
||||
# 的网关内模型,换成 worker 默认会跑错网关),所以
|
||||
# active 下不满足即拦,不存在“先试 worker 默认”
|
||||
# 的中间态;运营方须先把网关模型写进能力目录,
|
||||
# 否则 unknown 同样拦(fail-closed,与 v8 一致)。
|
||||
# 例外:任务级显式模型按 Audit fix 4 分档——已知
|
||||
# 不满足才拦,unknown 只审计(见下),因为显式
|
||||
# 优先保护的是用户选了哪个模型,不是选的模型
|
||||
# 一定能跑;目录陈旧不应惩罚正确的显式选择。
|
||||
# 审计批 4(I-7):显式模型的 known/unknown 分档检查
|
||||
# 与网关无关——网关分支只决定“待查模型是哪一个”
|
||||
# (网关填充模型或显式模型),分档判定走第 3 批统一
|
||||
# 的 evaluate_capability。进入条件因此是“有策略且
|
||||
# (走了 sub2api 网关,或显式任务走了任一真实网关)”:
|
||||
# 非显式 + 非 sub2api 的任务根本不进块(它们模型
|
||||
# 为空、后续走 decide_routing,不存在误拦);显式
|
||||
# + 无网关(provider 为 None)的任务也不进块——它
|
||||
# 根本没走网关,显式语义不变(写回模型仍走后续
|
||||
# 复核,用户显式仍不被复核,见 fixes5 契约);
|
||||
# 显式 + sub2api 路径与原来逐字一致(同一审计
|
||||
# action、同一 error 文案、同一 blocked 标志),
|
||||
# 不会重复审计或重复拦;显式 + 非 sub2api 网关
|
||||
# 在 active 下已知不满足时从“直接派发”变为“拦”,
|
||||
# 这是有意的行为变更。
|
||||
# 附带:已持久化的显式任务在非 sub2api 网关下会
|
||||
# 同时走本门与后续写回复核(本门不拦时复核再按
|
||||
# 分档审计/拦),两者审计 action 不同
|
||||
# (gateway_explicit 与 recheck_*),不是重复拦。
|
||||
if self.routing_policy is not None \
|
||||
and (gateway_provider == "sub2api"
|
||||
or (_explicit_model
|
||||
and gateway_provider is not None)):
|
||||
_gw_tier = classify_task(task)
|
||||
_gw_requires = self.routing_policy.tier_requirements.get(
|
||||
_gw_tier) or {}
|
||||
if _gw_requires:
|
||||
_gw_mode = self.routing_policy.tier_mode(_gw_tier)
|
||||
if _gw_mode in ("active", "shadow"):
|
||||
_gw_verdict = evaluate_capability(
|
||||
self.routing_policy.model_capabilities,
|
||||
task.get("model"), _gw_requires)
|
||||
# 审计批 4(I-10):走到这里说明网关
|
||||
# 能力检查真正算了 verdict。
|
||||
_mark_gate_checked(task, "capability")
|
||||
if not _gw_verdict.ok:
|
||||
_gw_note = ("gateway model %s %s capability:"
|
||||
" %s" % (
|
||||
task.get("model"),
|
||||
"cannot verify"
|
||||
if _gw_verdict.unknown
|
||||
else "lacks",
|
||||
", ".join(_gw_verdict.missing)))
|
||||
# Audit fix 4(第三遍审计分档建议):
|
||||
# 显式模型按 known/unknown 分档。known
|
||||
# 是目录明确写了不满足(布尔 False、
|
||||
# 或 context 低于需求),与网关默认
|
||||
# 模型同款 fail-fast;unknown 是目录
|
||||
# 无该模型、或条目缺字段,目录陈旧
|
||||
# 不应惩罚用户的正确选择,只审计不拦。
|
||||
# shadow 档两种情况都只审计。
|
||||
_gw_known = _gw_verdict.known_missing
|
||||
_gw_block = False
|
||||
_gw_error = None
|
||||
if _explicit_model:
|
||||
_gw_blocked = (
|
||||
_gw_mode == "active" and bool(_gw_known))
|
||||
# 显式检查结果独立审计,含 blocked
|
||||
# 标志:被拦的已知不满足也要进影子
|
||||
# 报告的显式失败清单,否则报告会
|
||||
# 漏掉恰恰最该被看到的一类。
|
||||
self._audit(
|
||||
task["project_id"],
|
||||
"worker_capability_gateway_explicit",
|
||||
"%s model=%s tier=%s mode=%s"
|
||||
" missing=%s unknown=%s blocked=%s"
|
||||
% (task_id, task.get("model"),
|
||||
_gw_tier, _gw_mode,
|
||||
",".join(_gw_verdict.missing) or "-",
|
||||
_gw_verdict.unknown,
|
||||
_gw_blocked))
|
||||
if _gw_blocked:
|
||||
_gw_block = True
|
||||
_gw_error = (
|
||||
"%s tier explicit model %s lacks"
|
||||
" capability: %s; refusing to"
|
||||
" dispatch a model known to lack"
|
||||
" a required capability"
|
||||
% (_gw_tier, task.get("model"),
|
||||
", ".join(_gw_known)))
|
||||
elif _gw_mode == "shadow":
|
||||
self._audit(
|
||||
task["project_id"],
|
||||
"worker_capability_gateway_shadow",
|
||||
"%s tier=%s %s (shadow, not blocked)"
|
||||
% (task_id, _gw_tier, _gw_note))
|
||||
else:
|
||||
_gw_block = True
|
||||
_gw_error = (
|
||||
"%s tier gateway model %s; refusing"
|
||||
" to dispatch an incapable or"
|
||||
" unverifiable model"
|
||||
% (_gw_tier, _gw_note))
|
||||
if _gw_block:
|
||||
self._set_status(task_id, "failed")
|
||||
self._write_execution_result(
|
||||
task_id, error_text=_gw_error,
|
||||
actual_model=self.model,
|
||||
started_at=started_at,
|
||||
finished_at=time.time(),
|
||||
only_if_status="failed")
|
||||
self._audit(
|
||||
task["project_id"],
|
||||
"worker_capability_gate_rejected",
|
||||
"%s: %s" % (task_id, _gw_error))
|
||||
log_event(
|
||||
self.logger,
|
||||
"worker_capability_gate_rejected",
|
||||
task_id=task_id,
|
||||
project_id=task["project_id"],
|
||||
status="failed")
|
||||
# Audit fix 3(M5):网关门拒绝
|
||||
# 早返回也要清暂存,同缺 key 门。
|
||||
self._cleanup_task_seed(task)
|
||||
self._running_clear(task_id)
|
||||
return {"dispatched": True,
|
||||
"task_id": task_id,
|
||||
"host_name":
|
||||
self.executor._host_name(task)
|
||||
if hasattr(self.executor,
|
||||
"_host_name")
|
||||
else task_id,
|
||||
"status": "failed",
|
||||
"executor": self._executor_name(),
|
||||
"stdout": "", "error": _gw_error,
|
||||
"events": [], "cost_record_id": None}
|
||||
# 审计批 5(I-9①):网关能力检查整段已抽成
|
||||
# _gateway_capability_check(纯平移,行为逐字不变,
|
||||
# 副作用清单见该方法文档串)。
|
||||
_gw_blocked = self._gateway_capability_check(
|
||||
task, gateway_provider, _explicit_model, started_at)
|
||||
if _gw_blocked is not None:
|
||||
return _gw_blocked
|
||||
|
||||
task = dict(task)
|
||||
if not task.get("model"):
|
||||
@@ -7793,36 +8052,7 @@ class Worker:
|
||||
# 网关路径不受影响:网关填模型的任务跳过
|
||||
# decide_routing 与写回,每次尝试都重新填网关
|
||||
# 模型并重新接受网关能力检查。
|
||||
_persisted = False
|
||||
if decision.chosen_model and decision.source == "policy" \
|
||||
and decision.applied:
|
||||
try:
|
||||
_wb_conn = _db_connect(self.db_path)
|
||||
try:
|
||||
_wb_conn.execute(
|
||||
"UPDATE tasks SET model = ? WHERE id = ?"
|
||||
" AND (model IS NULL OR model = '')",
|
||||
(decision.chosen_model, task_id))
|
||||
_wb_conn.commit()
|
||||
finally:
|
||||
_wb_conn.close()
|
||||
_persisted = True
|
||||
except Exception as exc:
|
||||
log_event(self.logger, "worker_model_writeback_failed",
|
||||
task_id=task_id,
|
||||
project_id=task["project_id"],
|
||||
status="warning",
|
||||
message="model writeback failed: %s" % exc)
|
||||
if _persisted:
|
||||
# 写回模型的身份标记:后续尝试据此区分
|
||||
# 写回模型与用户显式模型(见
|
||||
# _is_persisted_model)。审计本身 best-effort,
|
||||
# 丢了也不影响写回,旧模型靠 policy 行兜底。
|
||||
self._audit(task["project_id"],
|
||||
"worker_model_persisted",
|
||||
"%s tier=%s model=%s" % (
|
||||
task_id, decision.tier,
|
||||
decision.chosen_model))
|
||||
self._persist_policy_model(task, decision)
|
||||
task["model"] = decision.chosen_model or self.model
|
||||
elif not _gateway_filled:
|
||||
# Audit fix 3(I8):显式模型任务(含协调员子任务
|
||||
@@ -7896,6 +8126,13 @@ class Worker:
|
||||
# 宣称运行时按工具拦截:host 的 elicitation 回答是
|
||||
# 全局开关(auto 接受、其余拒绝),没有按工具分流,
|
||||
# ask 在今天没有询问通道,见 agent_def.py 的 v9 边界。
|
||||
# 审计批 5(I-12):派发时真的调 effective_tool_approvals
|
||||
# 并把 effective 映射写进同一审计行——此前模块文档称
|
||||
# “派发时审计其有效结果”,实际只记了声明本身。host 的
|
||||
# 全局开关取自传给 host 的 --config 文件里的 approval
|
||||
# (读不到/未配置时按 host 自身的默认 deny 计,与
|
||||
# host.py 的 args.approval 回落同款);ask_honoured 恒为
|
||||
# False,ask 在行里明确折算成 decline,不谎称“会问”。
|
||||
_tool_approvals = task.get("agent_tool_approvals") or {}
|
||||
if _tool_approvals:
|
||||
_denied_required = [
|
||||
@@ -7928,11 +8165,26 @@ class Worker:
|
||||
"executor": self._executor_name(),
|
||||
"stdout": "", "error": _appr_error, "events": [],
|
||||
"cost_record_id": None}
|
||||
_host_approval = "deny"
|
||||
_cfg_path = getattr(self.executor, "config_path", None)
|
||||
if _cfg_path:
|
||||
try:
|
||||
with open(_cfg_path, encoding="utf-8") as _fh:
|
||||
_host_approval = (
|
||||
json.load(_fh).get("approval") or "deny")
|
||||
except Exception:
|
||||
_host_approval = "deny"
|
||||
_effective_approvals = _effective_tool_approvals(
|
||||
_tool_approvals, _host_approval)
|
||||
self._audit(task["project_id"],
|
||||
"worker_tool_approvals_declared",
|
||||
"%s: %s (per-tool interception is not enforced"
|
||||
" at runtime; host answers elicitation globally)"
|
||||
" effective=%s"
|
||||
% (task_id, json.dumps(_tool_approvals,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True),
|
||||
json.dumps(_effective_approvals,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True)))
|
||||
# agent 必需工具门(v3):协调员父任务已在前面返回,
|
||||
|
||||
Reference in new issue
Block a user