Coordinator parts gain three enforced bounds, validated in validate_parts and carried through the coordinator_parts JSON (no schema change): timeout_seconds (1..86400, per-attempt wall clock from coordinator start; the coordinator poll loop cancels only that child with the same cancel mechanism as the global timeout, other parts keep waiting, audit coordinator_child_bound_exceeded), on_failure fail|tolerate (tolerated failures do not fail the coordinator, are named in the report head, and their failed children are not requeued on coordinator re-run while interrupted children still are), and result_chars (0..2000, default 500, enforced in build_report; the 4000-char report writeback cap still applies). evaluate_outcome is the single outcome rule used by both build_report and the worker error text, so tolerated failures never leak into the coordinator failure reason. Unknown part names fail closed. Deliberately NOT added, after reading the execution layer: per-part token/cost budget (cost is recorded only on completion, so a running child's spend is unobservable and creator quota is monthly, not per part), per-part round cap (normal tasks have no per-task round cap in the worker), and write-scope isolation (sandbox is per-project worktree, a scope string in a prompt would be an instruction, not a guarantee). Tests: test_delegation_bounds.py (8 tests).
242 lines
9.5 KiB
Python
242 lines
9.5 KiB
Python
"""有界委派 v5 单测:分工边界校验、容忍评价、回传截断、
|
|
单分工超时取消、容忍分工重跑不重排。
|
|
|
|
运行(在仓库根目录下):
|
|
python3 -m unittest discover -s cloud/tests -p "test_delegation_bounds.py" -v
|
|
|
|
说明:纯函数部分不碰库;worker 部分用临时 SQLite + 假
|
|
runner,子任务终态由测试直接写库模拟(与
|
|
test_coordinator 同款骨架),不跑真实模型、不联网。
|
|
关键断言:慢分工超时只取消该分工、不拖到全局超时;
|
|
容忍分工失败不拖垮协调员、且重跑时不重排;未知分工
|
|
按 fail 处理;token/轮数/写范围字段不在本轨——查代码
|
|
后确认执行层无法执行,故不以字段冒充(见
|
|
cloud/coordinator.py 模块注释)。
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from cloud.coordinator import build_report, evaluate_outcome
|
|
from cloud.coordinator import validate_parts
|
|
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.coordinator import validate_parts
|
|
from cloud.server import init_db
|
|
from cloud.worker import Worker
|
|
|
|
|
|
def _insert_task(db_path, task_id, prompt="do work", status="queued",
|
|
project_id="p1", coordinator_parts=None,
|
|
parent_task_id=None, coordinator_part=None,
|
|
result_text=None, error_text=None):
|
|
now = time.time()
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
import json
|
|
conn.execute(
|
|
"INSERT INTO tasks(id, project_id, prompt, status,"
|
|
" idempotency_key, depends_on, priority, created_by,"
|
|
" created_at, updated_at, last_heartbeat, coordinator_parts,"
|
|
" parent_task_id, coordinator_part, result_text, error_text)"
|
|
" VALUES(?, ?, ?, ?, NULL, '[]', 0, 'tester', ?, ?, NULL,"
|
|
" ?, ?, ?, ?, ?)",
|
|
(task_id, project_id, prompt, status, now, now,
|
|
json.dumps(coordinator_parts) if coordinator_parts else None,
|
|
parent_task_id, coordinator_part, result_text, error_text),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _row(db_path, task_id):
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT status, result_text, error_text FROM tasks"
|
|
" WHERE id = ?", (task_id,)).fetchone()
|
|
finally:
|
|
conn.close()
|
|
return {"status": row[0], "result_text": row[1],
|
|
"error_text": row[2]} if row else None
|
|
|
|
|
|
def _children(db_path, parent_id):
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT id FROM tasks WHERE parent_task_id = ?"
|
|
" ORDER BY coordinator_part", (parent_id,)).fetchall()
|
|
finally:
|
|
conn.close()
|
|
return [r[0] for r in rows]
|
|
|
|
|
|
class ValidateBoundsTestCase(unittest.TestCase):
|
|
def test_valid_bounds(self):
|
|
parts, err = validate_parts([{
|
|
"name": "a", "prompt": "x", "timeout_seconds": 60,
|
|
"on_failure": "tolerate", "result_chars": 100}])
|
|
self.assertIsNone(err)
|
|
self.assertEqual(parts[0]["timeout_seconds"], 60)
|
|
self.assertEqual(parts[0]["on_failure"], "tolerate")
|
|
self.assertEqual(parts[0]["result_chars"], 100)
|
|
|
|
def test_rejects_bad_bounds(self):
|
|
for bad in (
|
|
{"timeout_seconds": 0},
|
|
{"timeout_seconds": 86401},
|
|
{"timeout_seconds": True},
|
|
{"timeout_seconds": 1.5},
|
|
{"on_failure": "retry"},
|
|
{"result_chars": -1},
|
|
{"result_chars": 2001},
|
|
):
|
|
_parts, err = validate_parts(
|
|
[{"name": "a", "prompt": "x", **bad}])
|
|
self.assertIsNotNone(err, bad)
|
|
|
|
def test_old_parts_get_defaults(self):
|
|
parts, err = validate_parts([{"name": "a", "prompt": "x"}])
|
|
self.assertIsNone(err)
|
|
self.assertIsNone(parts[0]["timeout_seconds"])
|
|
self.assertEqual(parts[0]["on_failure"], "fail")
|
|
self.assertEqual(parts[0]["result_chars"], 500)
|
|
|
|
|
|
class OutcomeTestCase(unittest.TestCase):
|
|
def test_tolerated_failure_completes(self):
|
|
parts, _err = validate_parts([
|
|
{"name": "a", "prompt": "x"},
|
|
{"name": "b", "prompt": "x", "on_failure": "tolerate"}])
|
|
overall, failed, tolerated = evaluate_outcome([
|
|
{"name": "a", "status": "completed"},
|
|
{"name": "b", "status": "failed"}], parts)
|
|
self.assertEqual(overall, "completed")
|
|
self.assertEqual(failed, [])
|
|
self.assertEqual(tolerated, ["b"])
|
|
|
|
def test_unknown_part_fails_closed(self):
|
|
parts, _err = validate_parts([{"name": "a", "prompt": "x"}])
|
|
overall, failed, _tol = evaluate_outcome([
|
|
{"name": "ghost", "status": "failed"}], parts)
|
|
self.assertEqual(overall, "failed")
|
|
self.assertEqual(failed, ["ghost"])
|
|
|
|
def test_per_part_result_chars(self):
|
|
parts, _err = validate_parts([
|
|
{"name": "a", "prompt": "x", "result_chars": 10}])
|
|
report, _overall = build_report([
|
|
{"name": "a", "id": "1", "status": "completed",
|
|
"result_text": "x" * 100, "error_text": None}], parts)
|
|
self.assertIn("x" * 10, report)
|
|
self.assertNotIn("x" * 11, report)
|
|
|
|
|
|
class WorkerBoundsTestCase(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.env = {"XCM_API_KEY": "test-placeholder"}
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def _worker(self):
|
|
from types import SimpleNamespace
|
|
|
|
def _runner(cmd):
|
|
return SimpleNamespace(returncode=0, stdout="{}",
|
|
stderr="")
|
|
return Worker(self.db_path, "/fake/host.py",
|
|
runner=_runner, env=self.env)
|
|
|
|
def _parts(self):
|
|
return [
|
|
{"name": "快", "prompt": "做快活", "timeout_seconds": 60},
|
|
{"name": "慢", "prompt": "做慢活", "timeout_seconds": 1},
|
|
]
|
|
|
|
def test_per_part_timeout_cancels_only_slow_part(self):
|
|
# 快分工已完成,慢分工排队未终态:协调员本轮开始已
|
|
# 超过慢分工的 1 秒上限(started_at 往前推 2 秒),
|
|
# 循环应只取消慢分工、不等到 60 秒全局超时。
|
|
_insert_task(self.db_path, "coord",
|
|
coordinator_parts=self._parts())
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.execute("UPDATE tasks SET status = 'running'"
|
|
" WHERE id = 'coord'")
|
|
conn.commit()
|
|
conn.close()
|
|
worker = self._worker()
|
|
task = worker.get_task("coord")
|
|
children = worker._coord_sync_children(task, task["coordinator_parts"])
|
|
self.assertEqual(len(children), 2)
|
|
# 快分工直接标完成,慢分工保持排队:下一轮循环里
|
|
# 慢分工被单分工超时取消、快分工不动,等齐立即结束。
|
|
by_name = {}
|
|
conn = sqlite3.connect(self.db_path)
|
|
try:
|
|
for cid, part in conn.execute(
|
|
"SELECT id, coordinator_part FROM tasks"
|
|
" WHERE parent_task_id = 'coord'").fetchall():
|
|
by_name[part] = cid
|
|
conn.execute(
|
|
"UPDATE tasks SET status = 'completed',"
|
|
" result_text = 'ok' WHERE id = ?",
|
|
(by_name["快"],))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
started = time.time()
|
|
result = worker._run_coordinator(
|
|
worker.get_task("coord"), time.time() - 2,
|
|
poll_interval=0.01, timeout=60)
|
|
self.assertLess(time.time() - started, 10)
|
|
self.assertEqual(result["status"], "failed")
|
|
self.assertIn("有界委派超时", result["stdout"] or "")
|
|
self.assertEqual(_row(self.db_path, by_name["快"])["status"],
|
|
"completed")
|
|
self.assertEqual(_row(self.db_path, by_name["慢"])["status"],
|
|
"interrupted")
|
|
|
|
def test_tolerated_failed_not_requeued(self):
|
|
parts = [
|
|
{"name": "主", "prompt": "做主活"},
|
|
{"name": "副", "prompt": "做副活", "on_failure": "tolerate"},
|
|
]
|
|
_insert_task(self.db_path, "coord", coordinator_parts=parts)
|
|
_insert_task(self.db_path, "main", status="completed",
|
|
parent_task_id="coord", coordinator_part="主",
|
|
result_text="ok")
|
|
_insert_task(self.db_path, "side", status="failed",
|
|
parent_task_id="coord", coordinator_part="副",
|
|
error_text="炸了")
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.execute("UPDATE tasks SET status = 'running'"
|
|
" WHERE id = 'coord'")
|
|
conn.commit()
|
|
conn.close()
|
|
worker = self._worker()
|
|
task = worker.get_task("coord")
|
|
children = worker._coord_sync_children(
|
|
task, task["coordinator_parts"])
|
|
self.assertEqual(len(children), 2)
|
|
# 副分工的 failed 子任务未被回 queued,仍是 failed。
|
|
self.assertEqual(_row(self.db_path, "side")["status"], "failed")
|
|
result = worker._run_coordinator(
|
|
task, time.time(), poll_interval=0.01, timeout=60)
|
|
self.assertEqual(result["status"], "completed")
|
|
self.assertIn("已容忍", result["stdout"] or "")
|