feat: coordinator audit-loop wiring at merge point
Parent audit loop sits after all children are aggregated and before the parent final state; the single-task _audit_loop_wire is never applied to parents or children. Parent gates are per-child gates child:<name> (completed only); execution failures map to one deterministic finding per child; subjective findings are split off before evaluate and only audited as needs_review. Gate-rejected children (capability/tool/approval audit rows, or distinctive error markers) bypass evaluate and escalate directly without consuming a round; tolerated, paused and quota-missing parts are excluded, never findings. Rounds are counted only in the parent task_audit_loop_state; on pushback the packet is stored on the parent and split per failed child into that child's audit_loop_packet column, consumed once by the existing packet-injection path at next claim; pass and escalate clear parent and child packets. POST /tasks now allows audit_loop on coordinator parents (the old 400 rationale is gone); children still take no external switch.
This commit is contained in:
1 parent
f2005689fd
commit
017f76010f
4 files changed
+1012
-31
No files matched your search
@@ -0,0 +1,444 @@
|
||||
"""协调员审计循环接线测试:合流点 + 包拆分 + 开关落库调整。
|
||||
|
||||
只用 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.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.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)
|
||||
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:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'failed',"
|
||||
" error_text = 'boom' WHERE id = 'C1'")
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'completed',"
|
||||
" result_text = 'done' WHERE id = 'C2'")
|
||||
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)")
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log 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()
|
||||
Reference in new issue
Block a user