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

333 lines
14 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.
"""协调员审计循环复审修复测试:B1 + I1 + I2 + I3。
只用 init_db 的本地库与注入的假 runner,不跑真实模型回合、
不联网。覆盖(按任务书):
- B1:旧门拒绝审计行(早于本轮 started_at)不再误判新失败,
子任务执行失败应推回而非升级;本轮门拒绝审计行仍升级;
started_at 未知时保守保留门拒绝(fail-closed 向升级)。
- I1:merged 计完成——evaluate_outcome / build_report /
合流 pass 三处对齐;sync 不把 merged 子任务重排。
- I2:合流推回事务里父 CAS 失败时,子任务不被孤儿重排、
包列不写、且有明确审计;推回成功时父任务已由合流
CAS 回 queued(hook 不再另做父 CAS)。
- I3:推回存库分支异常时走升级而非回正常终局,error
写明存库失败、记 escalated 审计;已消耗的轮次不恢复。
"""
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.coordinator import build_report, evaluate_outcome
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.coordinator import build_report, evaluate_outcome
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, 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,"
" coordinator_parts)"
" VALUES(?, 'p1', 'do', ?, NULL, '[]', 0, 'tester',"
" ?, ?, NULL, ?, ?, ?, ?, ?, ?)",
(task_id, status, now, now, audit_loop, parent, part,
result, error,
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 _set_started(db_path, task_id, started_at):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET started_at = ? WHERE id = ?",
(started_at, task_id))
conn.commit()
finally:
conn.close()
def _gate_audit(db_path, detail, created_at):
conn = sqlite3.connect(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', ?, ?)",
(detail, created_at))
conn.commit()
finally:
conn.close()
def _completed_runner(cmd):
return SimpleNamespace(returncode=0, stdout="{}", stderr="")
class CoordFixTestCase(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 = Worker(self.db_path, "/fake/host.py",
runner=_completed_runner, env=self.env,
max_retries=0, retry_backoff_seconds=0)
self.worker._audit = lambda p, a, d="": self.audits.append((p, a, d))
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 _parent(self, task_id="P1"):
return {"id": task_id, "project_id": "p1", "audit_loop": 1}
def _parts(self, *names):
return [{"name": n, "on_failure": "fail"} for n in names]
# ---- B1 ----
def test_b1_old_gate_audit_does_not_block_pushback(self):
# 门拒绝 → 修复 → 执行失败:旧审计行早于本轮 started_at,
# 必须被作用域过滤掉,子任务按执行失败推回而非升级。
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="boom")
now = time.time()
_set_started(self.db_path, "C1", now)
_gate_audit(self.db_path, "C1: old gate rejection", now - 100)
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "pushback")
self.assertEqual(_row(self.db_path, "C1")[0], "queued")
def test_b1_current_gate_audit_still_escalates(self):
# 本轮门拒绝审计行晚于 started_at,仍应升级、不耗轮次。
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="gateway exploded")
now = time.time()
_set_started(self.db_path, "C1", now)
_gate_audit(self.db_path, "C1: new gate rejection", now + 10)
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "escalate")
self.assertEqual(_row(self.db_path, "C1")[0], "failed")
def test_b1_unknown_started_at_is_conservative(self):
# started_at 未知(老行/缺列)时保守保留门拒绝,向升级
# fail-closed,绝不因起始未知就把门拒绝任务推回。
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="gateway exploded")
_gate_audit(self.db_path, "C1: gate rejection",
time.time())
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "escalate")
def test_b1_m1_id_tiebreak_with_id_column(self):
# M1:生产 audit_log 有 id 列时,_coord_gate_rejected_
# child_ids 走 ORDER BY created_at DESC, id DESC 的
# 第一条查询;B1 旧测试用的无 id 列建表只覆盖了回落
# 路径,tie-break 语义(同 created_at 时 id 倒序)
# 没有任何断言。
#
# 诚实的设计说明:该函数返回的是子任务 id 集合而非
# 单行,所以“同一子任务多行、同 created_at”时无论
# 取哪行结果都一样——那样的测试是假测试,抓不到取
# 错行。tie-break 唯一可观测的后果在 5000 行封顶
# 处:同 created_at 时 id 倒序保留最新 5000 行、丢
# 最旧一行;若改成 id 正序或落到无 id 排序的回落
# 路径,则正好相反(丢最新一行、留最旧一行,见
# /tmp 实测:三条查询在 5001 行同 created_at 下
# 的结果互为反面)。因此用 5001 行同 created_at
# 压封顶边界断言:最新 id 的子任务必须在集合里、
# 最旧 id 的子任务必须不在,且集合恰好 5000 条。
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"CREATE TABLE audit_log(id INTEGER PRIMARY KEY"
" AUTOINCREMENT, user_id TEXT, project_id TEXT,"
" action TEXT, detail TEXT,"
" created_at DOUBLE PRECISION)")
now = time.time()
conn.executemany(
"INSERT INTO audit_log(user_id, project_id,"
" action, detail, created_at)"
" VALUES('system', 'p1',"
" 'worker_capability_gate_rejected', ?, ?)",
[("C%04d: gate rejection" % i, now)
for i in range(5001)])
conn.commit()
finally:
conn.close()
child_ids = ["C%04d" % i for i in range(5001)]
found = self.worker._coord_gate_rejected_child_ids(
"p1", child_ids,
attempt_started_at={cid: now - 100
for cid in child_ids})
self.assertEqual(len(found), 5000)
# 最新 id 的行(C5000)必须被 id 倒序保留下来。
self.assertIn("C5000", found)
# 最旧 id 的行(C0000)必须被 5000 行封顶挤掉;
# 若排序改成 id 正序或落到回落路径,这里会反过来
# (C5000 被挤掉、C0000 留下),断言即失败。
self.assertNotIn("C0000", found)
# ---- I1 ----
def test_i1_merged_counts_as_completed_everywhere(self):
overall, failed, _tol = evaluate_outcome(
[{"name": "a", "status": "merged"},
{"name": "b", "status": "completed"}],
[{"name": "a"}, {"name": "b"}])
self.assertEqual(overall, "completed")
self.assertEqual(failed, [])
_report, overall2 = build_report(
[{"name": "a", "id": "C1", "status": "merged",
"result_text": "done", "error_text": ""},
{"name": "b", "id": "C2", "status": "completed",
"result_text": "done", "error_text": ""}],
[{"name": "a"}, {"name": "b"}])
self.assertEqual(overall2, "completed")
# 合流:merged 子任务按完成计,审计 pass。
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="merged", parent="P1",
part="a", result="done")
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "pass")
def test_i1_sync_does_not_requeue_merged_child(self):
# sync 把 merged 当完成直接复用,绝不回 queued 清结果。
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="merged", parent="P1",
part="a", result="done")
self.worker._quota_exceeded = lambda *a: None
children = self.worker._coord_sync_children(
{"id": "P1", "project_id": "p1", "audit_loop": 1,
"created_by": "tester", "priority": 0},
[{"name": "a", "prompt": "do a"}])
self.assertEqual(children[0]["id"], "C1")
row = _row(self.db_path, "C1")
self.assertEqual(row[0], "merged")
self.assertEqual(row[1], "done")
# ---- I2 ----
def test_i2_parent_cas_failure_rolls_back_children(self):
# 父任务状态已被改动(模拟并发取消):合流事务回滚,
# 子任务不被孤儿重排、包列不写、且有明确审计。
_insert(self.db_path, "P1", status="interrupted",
audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="boom")
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "pushback_parent_cas_failed")
self.assertEqual(_row(self.db_path, "C1")[0], "failed")
self.assertIsNone(_packet(self.db_path, "C1"))
self.assertIsNone(_packet(self.db_path, "P1"))
self.assertTrue(any(
a == "worker_coord_audit_loop_pushback_parent_cas_failed"
for _, a, _ in self.audits))
def test_i2_pushback_parent_already_queued_by_merge(self):
# 推回成功时父任务已由合流事务 CAS 回 queued(hook
# 不再另做父 CAS);子任务重排、包可读。
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="boom")
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
self.assertEqual(out["decision"], "pushback")
self.assertEqual(_row(self.db_path, "P1")[0], "queued")
self.assertEqual(_row(self.db_path, "C1")[0], "queued")
self.assertIsNotNone(_packet(self.db_path, "C1"))
# ---- I3 ----
def test_i3_pushback_store_failure_escalates(self):
# 推回存库分支内任何异常都走升级而非回正常终局:
# 用拆分包抛异常模拟存库分支异常(该异常位于同一
# try 块内,与真实 DB 写失败走同一条 except 路径)。
_insert(self.db_path, "P1", status="running", audit_loop=1)
_insert(self.db_path, "C1", status="failed", parent="P1",
part="a", error="boom")
orig = Worker._coord_split_packet
try:
Worker._coord_split_packet = staticmethod(
lambda *a: (_ for _ in ()).throw(
RuntimeError("db write failed")))
out = self.worker._coord_audit_loop_merge(
self._parent(), self._parts("a"),
[{"name": "a", "id": "C1"}])
finally:
Worker._coord_split_packet = orig
self.assertEqual(out["decision"], "escalate")
self.assertIn("推回存库失败", out["error"])
self.assertTrue(any(a == "worker_coord_audit_loop_escalated"
for _, a, _ in self.audits))
# 升级清包:父包与子包都不应残留。
self.assertIsNone(_packet(self.db_path, "P1"))
self.assertIsNone(_packet(self.db_path, "C1"))
if __name__ == "__main__":
unittest.main()