Files
codex-app-server/cloud/tests/test_audit_loop_coord.py
T

473 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""协调员审计循环接线测试:合流点 + 包拆分 + 开关落库调整。
只用 init_db 的本地库与注入的假 runner,不跑真实模型回合、
不联网。覆盖(按任务书):合流推回(失败子任务重排、包只含
自己的 finding、父任务包可读、其余子任务不动);合流通过
(包清理);前置门拒绝直接升级、不耗轮次、不重排子任务;
容忍/用户取消/配额缺失子任务不进门、不成 finding;3 轮仍
失败升级;主观与确定性拆分(helper 层);sync 创建与重排时
的包拆分投递;POST /tasks 协调员带 audit_loop 改为接受。
注意:合流逻辑测试直接调 _coord_audit_loop_merge(合流点本身),
不跑完整 _run_coordinator 轮询循环——轮询要等子任务终态,
这里子任务行已是终态,合流逻辑与 _run_coordinator 调用的
是同一函数;另有两例线程测试跑完整 _run_coordinator(子任务
在本轮轮询期间终态,模拟 worker 完成),覆盖父任务 CAS 回
queued 与升级终局两条分支。
"""
import json
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.audit_loop import Finding
from cloud.auth import AuthStore
from cloud.server import init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.audit_loop import Finding
from cloud.auth import AuthStore
from cloud.server import init_db
from cloud.worker import Worker
def _insert(db_path, task_id, status="queued", parent=None, part=None,
result=None, error=None, audit_loop=0, paused=0, reason=None,
parts=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,"
" parent_task_id, coordinator_part, result_text, error_text,"
" paused_by_user, interrupted_reason, coordinator_parts)"
" VALUES(?, 'p1', 'do', ?, NULL, '[]', 0, 'tester',"
" ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, status, now, now, audit_loop, parent, part,
result, error, paused, reason,
json.dumps(parts) if parts is not None else None))
conn.commit()
finally:
conn.close()
def _row(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
return conn.execute(
"SELECT status, result_text, error_text, audit_loop_packet"
" FROM tasks WHERE id = ?", (task_id,)).fetchone()
finally:
conn.close()
def _packet(db_path, task_id):
row = _row(db_path, task_id)
if row is None or row[3] is None:
return None
return json.loads(row[3])
def _state(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT round, last_decision FROM task_audit_loop_state"
" WHERE task_id = ?", (task_id,)).fetchone()
except sqlite3.OperationalError:
return None
finally:
conn.close()
return row
def _completed_runner(cmd):
return SimpleNamespace(returncode=0, stdout="{}", stderr="")
class CoordAuditTestCase(unittest.TestCase):
def setUp(self):
self._old = os.environ.get("CODEX_PUSH_ALLOW_PRIVATE_TARGETS")
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = "1"
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
# Audit batch 2 (I-5): 合流的门拒绝审计查询失败时走
# escalate,不再回落空集。生产里 audit_log 由首次
# 真实 _audit 经 AuthStore 建好,合流时表一定存在;
# 本文件把 _audit 桩成列表,表永远不会出现,所以
# 在夹具里按生产实际补建表——否则所有合流测试都会
# 因缺表改走 escalate,与被测行为无关。
_store = AuthStore(self.db_path)
_store.conn.close()
self.env = {"XCM_API_KEY": "test-placeholder"}
self.audits = []
self.worker = self._worker()
conn = sqlite3.connect(self.db_path)
try:
self.worker._ensure_result_columns(conn)
conn.commit()
finally:
conn.close()
def tearDown(self):
if self._old is None:
os.environ.pop("CODEX_PUSH_ALLOW_PRIVATE_TARGETS", None)
else:
os.environ["CODEX_PUSH_ALLOW_PRIVATE_TARGETS"] = self._old
self.tmp.cleanup()
def _worker(self):
worker = Worker(self.db_path, "/fake/host.py",
runner=_completed_runner, env=self.env,
max_retries=0, retry_backoff_seconds=0)
worker._audit = lambda p, a, d="": self.audits.append((p, a, d))
return worker
def _parent(self, task_id="P1"):
return {"id": task_id, "project_id": "p1", "audit_loop": 1}
def _parts(self, *names, tolerated=()):
return [{"name": n,
"on_failure": "tolerate" if n in tolerated else "fail"}
for n in names]
def test_split_findings(self):
det = Finding("child:a", "high", "e", "r", "a",
kind="deterministic", gate="child:a")
sub = Finding("s1", "low", "e", "r", "a", kind="subjective")
d, s = Worker._coord_split_findings([det, sub])
self.assertEqual([f.id for f in d], ["child:a"])
self.assertEqual([f.id for f in s], ["s1"])
def test_split_packet_only_own_finding(self):
packet = {"task_id": "P1", "round": 2,
"findings": [
{"id": "child:a", "severity": "high",
"evidence": "ea", "reproduction": "ra",
"acceptance": "aa", "kind": "deterministic",
"gate": "child:a"},
{"id": "child:b", "severity": "high",
"evidence": "eb", "reproduction": "rb",
"acceptance": "ab", "kind": "deterministic",
"gate": "child:b"}],
"criteria": {"child:a": "child:a",
"child:b": "child:b"}}
split = Worker._coord_split_packet(packet, "a")
self.assertEqual([f["id"] for f in split["findings"]],
["child:a"])
self.assertEqual(split["criteria"], {"child:a": "child:a"})
self.assertEqual(split["round"], 2)
self.assertIsNone(Worker._coord_split_packet(packet, "zzz"))
def test_merge_pushback(self):
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="boom")
_insert(self.db_path, "C2", status="completed", parent="P1",
part="b", result="done")
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a", "b"),
[{"name": "a", "id": "C1"}, {"name": "b", "id": "C2"}])
self.assertEqual(out["decision"], "pushback")
row_a = _row(self.db_path, "C1")
self.assertEqual(row_a[0], "queued")
self.assertIsNone(row_a[1])
packet_a = _packet(self.db_path, "C1")
self.assertEqual([f["id"] for f in packet_a["findings"]],
["child:a"])
self.assertEqual(_row(self.db_path, "C2")[0], "completed")
parent_packet = _packet(self.db_path, "P1")
self.assertEqual([f["id"] for f in parent_packet["findings"]],
["child:a"])
self.assertEqual(_state(self.db_path, "P1")[0], 1)
self.assertTrue(any(a == "worker_coord_audit_loop_pushback"
for _, a, _ in self.audits))
def test_merge_pass_clears_packets(self):
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="completed", parent="P1",
part="a", result="done")
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET audit_loop_packet = ? WHERE id = ?",
('{"round": 1, "findings": []}', "C1"))
conn.execute(
"UPDATE tasks SET audit_loop_packet = ? WHERE id = ?",
('{"round": 1, "findings": []}', "P1"))
conn.commit()
finally:
conn.close()
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "pass")
self.assertIsNone(_packet(self.db_path, "C1"))
self.assertIsNone(_packet(self.db_path, "P1"))
def test_merge_gate_rejected_escalates_without_round(self):
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="gateway exploded")
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS audit_log(user_id TEXT,"
" project_id TEXT, action TEXT, detail TEXT,"
" created_at DOUBLE PRECISION)")
conn.execute(
"INSERT INTO audit_log(user_id, project_id, action,"
" detail, created_at) VALUES('system', 'p1',"
" 'worker_capability_gate_rejected', ?, ?)",
("C1: tier gateway model X; refusing to dispatch", time.time()))
conn.commit()
finally:
conn.close()
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "escalate")
self.assertIn("a", out["error"])
# 不耗轮次:状态表里没有该父任务的行;子任务不重排。
self.assertIsNone(_state(self.db_path, "P1"))
self.assertEqual(_row(self.db_path, "C1")[0], "failed")
self.assertTrue(any(a == "worker_coord_audit_loop_escalated"
for _, a, _ in self.audits))
def test_merge_gate_rejected_by_error_marker(self):
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="Missing environment variable: XCM_API_KEY")
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "escalate")
def test_merge_excluded_children_pass(self):
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="tolerated boom")
_insert(self.db_path, "C2", status="failed", parent="P1",
part="b", error="user stopped", paused=1)
_insert(self.db_path, "C3", status="completed", parent="P1",
part="c", result="done")
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a", "b", "c", tolerated=("a",)),
[{"name": "a", "id": "C1"}, {"name": "b", "id": "C2"},
{"name": "c", "id": "C3"}])
self.assertEqual(out["decision"], "pass")
# 配额缺失的分工不在 children 里:不列门、不成 finding。
out2 = self.worker._coord_audit_loop_merge(
self._parent("P2"),
self._parts("c"),
[{"name": "c", "id": "C3"}])
self.assertEqual(out2["decision"], "pass")
def test_merge_max_rounds_escalates(self):
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="boom again")
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT INTO task_audit_loop_state(task_id, round,"
" passed_gates, criteria, last_decision, updated_at)"
" VALUES('P1', 3, '[]', ?, 'pushback', ?)",
('{"child:a": "child:a"}', time.time()))
conn.commit()
finally:
conn.close()
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "escalate")
self.assertIsNone(_packet(self.db_path, "P1"))
self.assertIsNone(_packet(self.db_path, "C1"))
def test_merge_switch_off_skips(self):
_insert(self.db_path, "P1", status="running", audit_loop=0)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="boom")
out = self.worker._coord_audit_loop_merge(
{"id": "P1", "project_id": "p1", "audit_loop": 0},
self._parts("a"), [{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "skip")
self.assertEqual(_row(self.db_path, "C1")[0], "failed")
def test_sync_creation_and_requeue_attach_packet(self):
parent_packet = {
"task_id": "P1", "round": 1,
"findings": [{"id": "child:a", "severity": "high",
"evidence": "ea", "reproduction": "ra",
"acceptance": "aa", "kind": "deterministic",
"gate": "child:a"}],
"criteria": {"child:a": "child:a"}}
parent = {"id": "P1", "project_id": "p1", "audit_loop": 1,
"audit_loop_packet": json.dumps(parent_packet),
"created_by": "tester", "priority": 0}
self.worker._quota_exceeded = lambda *a: None
children = self.worker._coord_sync_children(
parent, [{"name": "a", "prompt": "do a"}])
self.assertEqual(len(children), 1)
packet = _packet(self.db_path, children[0]["id"])
self.assertIsNotNone(packet)
self.assertEqual([f["id"] for f in packet["findings"]],
["child:a"])
# 重排分支:已失败的子任务 + 父包 → 重排并贴拆分包。
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET status = 'failed', error_text = 'boom'"
" WHERE id = ?", (children[0]["id"],))
conn.execute(
"UPDATE tasks SET audit_loop_packet = NULL WHERE id = ?",
(children[0]["id"],))
conn.commit()
finally:
conn.close()
children2 = self.worker._coord_sync_children(
parent, [{"name": "a", "prompt": "do a"}])
self.assertEqual(children2[0]["id"], children[0]["id"])
row = _row(self.db_path, children[0]["id"])
self.assertEqual(row[0], "queued")
packet2 = _packet(self.db_path, children[0]["id"])
self.assertEqual([f["id"] for f in packet2["findings"]],
["child:a"])
def test_run_coordinator_pushback_full_path(self):
# 完整 _run_coordinator 路径:子任务在本轮轮询期间终态
# (线程里翻状态模拟 worker 完成),合流推回后父任务回
# queued、失败子任务回 queued 且带拆分包。直接插 failed
# 行测不到合流——sync 会在轮询前就把它重排走。
import threading
_insert(self.db_path, "P1", status="running", audit_loop=1,
parts=[{"name": "a", "prompt": "do a"},
{"name": "b", "prompt": "do b"}])
_insert(self.db_path, "C1", status="queued", parent="P1",
part="a")
_insert(self.db_path, "C2", status="queued", parent="P1",
part="b")
self.worker._quota_exceeded = lambda *a: None
box = {}
def _run():
box["result"] = self.worker._run_coordinator(
self.worker.get_task("P1"), time.time(),
poll_interval=0.01, timeout=10)
thread = threading.Thread(target=_run)
thread.start()
deadline = time.time() + 5
while time.time() < deadline:
conn = sqlite3.connect(self.db_path)
try:
# 翻状态与父任务终态判定必须在同一条语句里原子
# 完成:只在父任务仍 running 时翻子任务。旧写法
# 是无条件翻状态,合流提交推回(父回 queued、子
# 回 queued)之后、协调员线程退出之前,翻状态线
# 程的下一拍会把已重排的 C1 重新翻回 failed,慢
# 机器上即表现为“父 queued、子 failed”的假失败
# (Gitea run #20 唯一失败项正是此断言;产品侧
# 合流本身无问题,直接调合流的 test_merge_pushback
# 在同一轮 CI 里通过)。
conn.execute(
"UPDATE tasks SET status = 'failed',"
" error_text = 'boom' WHERE id = 'C1' AND"
" (SELECT status FROM tasks WHERE id = 'P1')"
" = 'running'")
conn.execute(
"UPDATE tasks SET status = 'completed',"
" result_text = 'done' WHERE id = 'C2' AND"
" (SELECT status FROM tasks WHERE id = 'P1')"
" = 'running'")
conn.commit()
finally:
conn.close()
time.sleep(0.05)
if not thread.is_alive():
break
thread.join(timeout=5)
self.assertFalse(thread.is_alive())
result = box["result"]
self.assertEqual(result["status"], "queued")
self.assertEqual(_row(self.db_path, "P1")[0], "queued")
self.assertEqual(_row(self.db_path, "C1")[0], "queued")
packet = _packet(self.db_path, "C1")
self.assertEqual([f["id"] for f in packet["findings"]],
["child:a"])
def test_run_coordinator_escalate_full_path(self):
# 门拒绝子任务在本轮终态 → 合流直接升级:父任务 failed、
# 错误点名分工、子任务不重排。
import threading
_insert(self.db_path, "P1", status="running", audit_loop=1,
parts=[{"name": "a", "prompt": "do a"}])
_insert(self.db_path, "C1", status="queued", parent="P1",
part="a")
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"CREATE TABLE IF NOT EXISTS audit_log(user_id TEXT,"
" project_id TEXT, action TEXT, detail TEXT,"
" created_at DOUBLE PRECISION)")
# Audit batch 2 (I-5):setUp 已按生产实际经 AuthStore
# 建好含 id 列的 audit_log 表,INSERT 必须显式列名,
# 否则在 6 列的生产表上会因 5 个值报错;显式列名在
# 旧 5 列手工表上同样成立。
conn.execute(
"INSERT INTO audit_log(user_id, project_id, action,"
" detail, created_at) VALUES('system', 'p1',"
" 'worker_tool_gate_rejected',"
" 'C1: agent 声明必需工具缺失:websearch', ?)",
(time.time(),))
conn.commit()
finally:
conn.close()
self.worker._quota_exceeded = lambda *a: None
box = {}
def _run():
box["result"] = self.worker._run_coordinator(
self.worker.get_task("P1"), time.time(),
poll_interval=0.01, timeout=10)
thread = threading.Thread(target=_run)
thread.start()
deadline = time.time() + 5
while time.time() < deadline:
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET status = 'failed',"
" error_text = 'agent 声明必需工具缺失:websearch'"
" WHERE id = 'C1'")
conn.commit()
finally:
conn.close()
time.sleep(0.05)
if not thread.is_alive():
break
thread.join(timeout=5)
self.assertFalse(thread.is_alive())
result = box["result"]
self.assertEqual(result["status"], "failed")
self.assertIn("审计循环升级", result["error"])
self.assertEqual(_row(self.db_path, "P1")[0], "failed")
self.assertEqual(_row(self.db_path, "C1")[0], "failed")
if __name__ == "__main__":
unittest.main()