Merge pull request #111 from openqj/feat-coord-row-vanished

协调员行消失按中断处理:本体行消失即放槽,子任务行消失按 interrupted 合流
This commit is contained in:
openqj authored and GitHub committed 2026-10-28 17:46:23 +08:00
commit eae9884b13
2 files changed
+223 -3

No files matched your search

@@ -0,0 +1,204 @@
"""协调员行消失单测:本体行消失与子任务行消失都不等到超时。
不跑真实模型回合、不联网:worker 侧用临时 SQLite + 假 runner,
等待中消失用“线程里跑协调员、主线程删行、join 带超时断言”
的办法——不用“删行后直接 run_one”(run_one 先认领,行已没了
根本认领不上),也不靠 sleep 时长赌竞态:删行后下一轮轮询
(poll_interval=0.05 秒)必然看到消失,join 超时即为失败信号。
"""
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.server import init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.server import init_db
from cloud.worker import Worker
def _completed_runner(cmd):
return SimpleNamespace(returncode=0, stdout="{}", stderr="")
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:
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 id, status, result_text, error_text FROM tasks"
" WHERE id = ?", (task_id,)).fetchone()
finally:
conn.close()
if row is None:
return None
return {"id": row[0], "status": row[1], "result_text": row[2],
"error_text": row[3]}
def _delete_row(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
conn.commit()
finally:
conn.close()
def _set_running(db_path, task_id):
conn = sqlite3.connect(db_path)
try:
conn.execute(
"UPDATE tasks SET status = 'running' WHERE id = ?",
(task_id,))
conn.commit()
finally:
conn.close()
class RowVanishedTestCase(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):
return Worker(self.db_path, "/fake/host.py",
runner=_completed_runner, env=self.env)
def _parts(self):
return [{"name": "后端", "prompt": "做后端"},
{"name": "前端", "prompt": "做前端"}]
def _run_in_thread(self, worker, task, timeout):
"""线程里跑 _run_coordinator,返回 (结果, 耗时);join 超时即失败。"""
box = {}
def _target():
started = time.time()
box["result"] = worker._run_coordinator(
task, time.time(), poll_interval=0.05, timeout=timeout)
box["elapsed"] = time.time() - started
thread = threading.Thread(target=_target)
thread.start()
return thread, box
def test_parent_row_vanished_exits_as_interrupted(self):
"""(a) 协调员本体行在等待中消失 → 远小于 timeout 退出,
返回 interrupted,不是 coordinator timeout 的 failed。"""
_insert_task(self.db_path, "coord",
coordinator_parts=self._parts())
_insert_task(self.db_path, "c1", status="completed",
parent_task_id="coord", coordinator_part="后端",
result_text="后端好了")
_insert_task(self.db_path, "c2", status="queued",
parent_task_id="coord", coordinator_part="前端")
_set_running(self.db_path, "coord")
worker = self._worker()
task = worker.get_task("coord")
thread, box = self._run_in_thread(worker, task, timeout=60)
time.sleep(0.3)
_delete_row(self.db_path, "coord")
thread.join(timeout=10)
self.assertFalse(thread.is_alive(),
"本体行消失后协调员循环应在 10 秒内退出")
self.assertLess(box["elapsed"], 10)
self.assertEqual(box["result"]["status"], "interrupted")
self.assertNotIn("timeout", box["result"]["error"] or "")
# 行已没了,终局写回写不回去,也不抛错、不当完成。
self.assertIsNone(_row(self.db_path, "coord"))
def test_child_row_vanished_counts_as_interrupted(self):
"""(b) 子任务行在等待中消失 → 合流报告里该子任务为
interrupted 且注明行已消失,整体不为 completed,其它
子任务终态不受影响,且不拖到 timeout。"""
_insert_task(self.db_path, "coord",
coordinator_parts=self._parts())
_insert_task(self.db_path, "c1", status="completed",
parent_task_id="coord", coordinator_part="后端",
result_text="后端好了")
_insert_task(self.db_path, "c2", status="queued",
parent_task_id="coord", coordinator_part="前端")
_set_running(self.db_path, "coord")
worker = self._worker()
task = worker.get_task("coord")
thread, box = self._run_in_thread(worker, task, timeout=60)
time.sleep(0.3)
_delete_row(self.db_path, "c2")
thread.join(timeout=10)
self.assertFalse(thread.is_alive(),
"子任务行消失后协调员应在 10 秒内合流退出")
self.assertLess(box["elapsed"], 10)
result = box["result"]
self.assertNotEqual(result["status"], "completed")
self.assertNotIn("timeout", result["error"] or "")
self.assertIn("前端", result["error"] or "")
self.assertIn("(interrupted)", result["stdout"])
self.assertIn("行已消失", result["stdout"])
self.assertIn("后端好了", result["stdout"])
# 其它子任务不受影响:c1 仍是 completed,结果 intact。
c1 = _row(self.db_path, "c1")
self.assertEqual(c1["status"], "completed")
self.assertEqual(c1["result_text"], "后端好了")
# 协调员本体行仍在,且因有子任务未完成而整体 failed。
self.assertEqual(_row(self.db_path, "coord")["status"], "failed")
def test_all_completed_still_passes(self):
"""(c) 正常等齐仍通过:行消失防线不改变正常合流语义。"""
_insert_task(self.db_path, "coord",
coordinator_parts=self._parts())
_insert_task(self.db_path, "c1", status="completed",
parent_task_id="coord", coordinator_part="后端",
result_text="后端好了")
_insert_task(self.db_path, "c2", status="completed",
parent_task_id="coord", coordinator_part="前端",
result_text="前端好了")
_set_running(self.db_path, "coord")
worker = self._worker()
result = worker._run_coordinator(
worker.get_task("coord"), time.time(),
poll_interval=0.01, timeout=60)
self.assertEqual(result["status"], "completed")
self.assertIn("2/2", result["stdout"])
self.assertEqual(_row(self.db_path, "coord")["status"], "completed")
if __name__ == "__main__":
unittest.main()