"""多进程端到端(docs/design/orchestration-round/00-总方案.md §5 W2-b 的验收):PostgreSQL 上起两个工作进程(uvicorn,A、B), 指着同一个库、同一个数据目录,用演示模型(Mock)跑: 1. 两个进程都登记了、只有一个当调度班; 2. 运行由接到请求的那个进程认领;另一个进程上的 WebSocket 收得到实时事件(含不落库的流式增量);同一个审批决定同时发给两个 进程只执行一次;两边的事件流都按 seq 递增、不重不漏,封存核对得上; 3. 在 A 上跑的运行,从 B 停止:照本进程的停止办(run.cancelled 带上是谁停的); 4. 定时任务到点只触发一次(两个进程都起着调度循环,只有当班的做); 5. 评测和问数据并发时优先级生效:全站只给 1 个名额(没有留底),评测的作答运行(系统类,由本测试进程作为第三个登记的工作进程 以评测的来源发起——工作进程里发起评测要真的去调模型建图)排着队时,后来的问数据运行(交互类,经 A 的接口发起)先拿到名额; 全站 2 个、给交互留底 1 个:评测借满了别的名额、还有题排着,问数据一来马上拿到留底,不等评测跑完; 6. 每日花费上限跨进程不越过:两个进程同时往同一本账上记,记下的合计不超过上限; 7. 强杀 B:租约过期后,B 上在跑的运行被照实标中断(可从断点继续),停在审批上的运行照常能在 A 上批、接着跑完;B 当着调度班的 话 A 接班; 8. 卡住但没死(复核 R-b1):A 在跑一次运行时被 SIGSTOP,B 等它的租约过期两倍时长后接手、标中断;A 缓过来(SIGCONT)之后发现续晚了, 停手、不写终态、不写事件,换一个身份重新登记——不管 B 有没有在这期间把那次运行接着跑完,都不双写、不盖掉终态、序号不重。 缺省不跑:要 AGENTLAB_MULTIPROCESS_E2E=1 和 PostgreSQL 测试库(scripts/test-multiprocess.sh 起临时容器、设好这两样)。 """ from __future__ import annotations import asyncio import json import os import signal import socket import subprocess import sys import time from pathlib import Path from typing import Any import pytest pytestmark = pytest.mark.skipif( os.environ.get("AGENTLAB_MULTIPROCESS_E2E") != "1" or not os.environ.get("AGENTLAB_TEST_DATABASE_URL"), reason="多进程端到端缺省不跑:用 scripts/test-multiprocess.sh(要本机 docker 的 postgres:16)") BACKEND = Path(__file__).resolve().parents[1] #: 工作进程的租约和心跳:调小,强杀之后几秒就能看到接手 HEARTBEAT_S = 0.5 LEASE_S = 2.0 def _free_port() -> int: with socket.socket() as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] def _node(nid: str, ntype: str, **config: Any) -> dict[str, Any]: return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} def _chain(*nodes: dict[str, Any]) -> dict[str, Any]: return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} def _sleep_code(seconds: float) -> dict[str, Any]: return _node("wait", "code", language="python", code=f"import time\ntime.sleep({seconds})\nprint('ok')") def slow_graph(seconds: float) -> dict[str, Any]: return _chain(_node("start", "input", fields=[{"name": "question"}]), _sleep_code(seconds), _node("out", "output", fields=[{"name": "r", "value": "{{ nodes.wait.text }}"}])) APPROVAL_GRAPH = _chain( _node("start", "input", fields=[{"name": "question"}]), _sleep_code(1.0), _node("gate", "human", mode="approve", title="看一眼再接着"), _node("write", "llm", prompt="用三句话写一段说明"), _node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.write.text }}"}]), ) class Worker: def __init__(self, name: str, env: dict[str, str], log_dir: Path) -> None: self.name = name self.port = _free_port() self.base = f"http://127.0.0.1:{self.port}" self.log = log_dir / f"worker-{name}.log" self.proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(self.port)], cwd=str(BACKEND), env=env, stdout=open(self.log, "wb"), stderr=subprocess.STDOUT) self.owner: str | None = None def kill(self) -> None: if self.proc.poll() is None: self.proc.send_signal(signal.SIGKILL) self.proc.wait(timeout=10) def pause(self) -> None: self.proc.send_signal(signal.SIGSTOP) def resume(self) -> None: if self.proc.poll() is None: self.proc.send_signal(signal.SIGCONT) def stop(self) -> None: if self.proc.poll() is None: self.proc.send_signal(signal.SIGCONT) self.proc.send_signal(signal.SIGTERM) try: self.proc.wait(timeout=15) except subprocess.TimeoutExpired: self.kill() def tail(self) -> str: try: return self.log.read_text(encoding="utf-8", errors="replace")[-12000:] except OSError: return "" def _worker_env() -> dict[str, str]: env = {k: v for k, v in os.environ.items() if k not in ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "PYTEST_XDIST_WORKER", "PYTEST_CURRENT_TEST")} env.update({ "PYTHONPATH": str(BACKEND), # 应用库、数据目录:和本测试进程同一个(conftest 已经设好) "AGENTLAB_DATABASE_URL": os.environ["AGENTLAB_DATABASE_URL"], "AGENTLAB_DATA_DIR": os.environ["AGENTLAB_DATA_DIR"], "AGENTLAB_DB_POOL_SIZE": "5", "AGENTLAB_SCHEDULER": "1", "AGENTLAB_SCHEDULER_TICK_S": "0.5", "AGENTLAB_WORKER_HEARTBEAT_S": str(HEARTBEAT_S), "AGENTLAB_WORKER_LEASE_S": str(LEASE_S), "AGENTLAB_SANDBOX_BACKEND": "local", "AGENTLAB_AUTH_MODE": "off", }) return env @pytest.fixture(scope="module") def workers(tmp_path_factory): import httpx log_dir = tmp_path_factory.mktemp("multiprocess") env = _worker_env() started = [Worker("A", env, log_dir), Worker("B", env, log_dir)] try: deadline = time.monotonic() + 90 for w in started: while True: if w.proc.poll() is not None: raise AssertionError(f"工作进程 {w.name} 没起来:\n{w.tail()}") try: if httpx.get(f"{w.base}/api/health", timeout=2).status_code == 200: break except httpx.HTTPError: pass if time.monotonic() > deadline: raise AssertionError(f"工作进程 {w.name} 90 秒没起来:\n{w.tail()}") time.sleep(0.3) w.owner = httpx.get(f"{w.base}/api/system", timeout=10).json()["cluster"]["me"] yield {w.name: w for w in started} finally: for w in started: w.stop() for w in started: print(f"---- 工作进程 {w.name} 的日志(末尾)----\n{w.tail()}") async def _client(): import httpx return httpx.AsyncClient(timeout=30) async def _until(check, timeout: float = 20.0, what: str = "") -> Any: deadline = time.monotonic() + timeout last = None while time.monotonic() < deadline: last = await check() if last: return last await asyncio.sleep(0.1) raise AssertionError(f"等超时了:{what}(最后一次:{last!r})") async def _run(client, worker: Worker, run_id: str) -> dict[str, Any]: return (await client.get(f"{worker.base}/api/runs/{run_id}")).json() async def _db(sql: str, *args: Any) -> list[Any]: from sqlalchemy import text from app.db.base import SessionLocal async with SessionLocal() as session: return list((await session.execute(text(sql), _params(args))).all()) def _params(args: tuple[Any, ...]) -> dict[str, Any]: return {f"p{i}": v for i, v in enumerate(args)} class Stream: """一条 WebSocket(/api/runs/{id}/stream)收到的全部消息。""" def __init__(self, worker: Worker, run_id: str) -> None: self.url = f"ws://127.0.0.1:{worker.port}/api/runs/{run_id}/stream?after=0" self.messages: list[dict[str, Any]] = [] self.task: asyncio.Task[None] | None = None async def start(self) -> Stream: import websockets ready = asyncio.Event() async def pump() -> None: async with websockets.connect(self.url) as ws: ready.set() async for raw in ws: msg = json.loads(raw) self.messages.append(msg) if msg.get("type") == "stream.end": return self.task = asyncio.create_task(pump()) await asyncio.wait_for(ready.wait(), timeout=10) return self def types(self) -> list[str]: return [m.get("type") for m in self.messages] def seqs(self) -> list[int]: """落库事件的 seq(不落库的流式增量另算)。""" return [int(m["seq"]) for m in self.messages if m.get("seq") and m.get("type") not in ("llm.token", "llm.thinking.delta")] async def ended(self, timeout: float = 30.0) -> None: assert self.task is not None await asyncio.wait_for(self.task, timeout=timeout) # -------------------------------------------------------------------------- async def test_1_two_workers_one_leader(workers): a, b = workers["A"], workers["B"] async with await _client() as client: async def both() -> Any: info = (await client.get(f"{a.base}/api/system")).json()["cluster"] alive = {w["id"] for w in info["workers"] if w["alive"]} return info if {a.owner, b.owner} <= alive else None info = await _until(both, what="两个进程都登记") assert info["database"] == "postgresql" assert info["leader"]["owner"] in (a.owner, b.owner) other = (await client.get(f"{b.base}/api/system")).json()["cluster"] assert other["leader"]["owner"] == info["leader"]["owner"], "两边看到的当班是同一个" async def test_2_live_events_across_workers_and_one_decision(workers): a, b = workers["A"], workers["B"] async with await _client() as client: made = (await client.post(f"{a.base}/api/runs", json={"graph": APPROVAL_GRAPH, "input": {"question": "q"}, "origin": "canvas"})).json() run_id = made["id"] # 两边各接一条流(趁它还在跑:停在审批上之后才接的流回放完就结束,要等恢复之后重新接) on_b = await Stream(b, run_id).start() on_a = await Stream(a, run_id).start() owner = await _until(lambda: _owner_while_active(run_id), what="运行被认领") assert owner == a.owner, "运行由接到请求的进程认领" async def paused() -> Any: return (await _run(client, b, run_id))["status"] == "interrupted" await _until(paused, what="停在审批上") await _until(lambda: _has(on_b, "run.interrupted"), what="B 上的流收到 run.interrupted") # 同一个决定同时发给两个进程:只执行一次 decided = await asyncio.gather( client.post(f"{a.base}/api/runs/{run_id}/resume", json={"response": {"approved": True}}), client.post(f"{b.base}/api/runs/{run_id}/resume", json={"response": {"approved": True}})) assert sorted(r.status_code for r in decided)[0] == 200, [r.text for r in decided] async def finished() -> Any: return (await _run(client, a, run_id))["status"] in ("succeeded", "failed") await _until(finished, timeout=60, what="运行跑完") assert (await _run(client, a, run_id))["status"] == "succeeded" await on_a.ended() await on_b.ended() for stream in (on_a, on_b): assert "run.finished" in stream.types(), stream.types() seqs = stream.seqs() assert seqs == sorted(set(seqs)), f"事件流有重复或乱序:{seqs}" started = [m for m in on_a.messages if m.get("type") == "node.started" and m.get("node_id") == "write"] assert len(started) == 1, "审批之后的那一步只执行了一次" # 执行后半段的进程不是谁、另一个进程上的流照样收到了流式增量(不落库,只转给在看的进程) assert any(m.get("type") == "llm.token" for m in on_a.messages) and \ any(m.get("type") == "llm.token" for m in on_b.messages), "两边都收到了正文增量" rows = await _db("SELECT seq, count(*) FROM run_events WHERE run_id = :p0 GROUP BY seq HAVING count(*) > 1", run_id) assert rows == [], f"库里的事件撞了号:{rows}" verified = (await client.get(f"{b.base}/api/runs/{run_id}/verify")).json() assert verified["ok"] is True, verified async def _owner_while_active(run_id: str) -> str | None: rows = await _db("SELECT owner, status FROM runs WHERE id = :p0", run_id) if rows and rows[0][1] in ("running", "queued") and rows[0][0]: return rows[0][0] return None async def _has(stream: Stream, kind: str) -> bool: return kind in stream.types() async def test_3_stop_from_the_other_worker(workers): a, b = workers["A"], workers["B"] async with await _client() as client: run_id = (await client.post(f"{a.base}/api/runs", json={"graph": slow_graph(20), "input": {"question": "q"}, "origin": "canvas"})).json()["id"] await _until(lambda: _owner_while_active(run_id), what="运行被认领") t0 = time.monotonic() stop = await client.post(f"{b.base}/api/runs/{run_id}/cancel", headers={"X-Actor": "e2e-stopper"}) assert stop.status_code == 200 and stop.json()["status"] == "stopping", stop.text async def cancelled() -> Any: row = await _run(client, b, run_id) return row if row["status"] == "cancelled" else None row = await _until(cancelled, timeout=15, what="停止生效") assert row["end_reason"] == "stopped" assert time.monotonic() - t0 < 10, "不是等它跑完才停的" events = (await client.get(f"{a.base}/api/runs/{run_id}/events")).json() stopped = [e for e in events if e["type"] == "run.cancelled"] assert stopped and stopped[-1]["data"].get("actor") == "e2e-stopper", stopped async def test_4_a_schedule_fires_once(workers): a = workers["A"] async with await _client() as client: wf = (await client.post(f"{a.base}/api/workflows", json={"name": "定时验收", "graph": slow_graph(0.1)})).json() made = await client.post(f"{a.base}/api/schedules", json={ "name": "每分钟一次", "workflow_id": wf["id"], "cron": "* * * * *", "timezone": "Asia/Shanghai", "run_class": "exploratory", "input_template": {"question": "定时"}}) assert made.status_code == 201, made.text schedule_id = made.json()["id"] # 拨到「刚过点」:两个进程的调度循环都在转,只有当班的那个做 from sqlalchemy import text from app.db.base import SessionLocal, utcnow due = utcnow().replace(microsecond=0) async with SessionLocal() as session: await session.execute(text("UPDATE schedules SET next_run_at = :t WHERE id = :i"), {"t": due.replace(tzinfo=None), "i": schedule_id}) await session.commit() async def fired() -> Any: rows = await _db("SELECT id FROM schedule_fires WHERE schedule_id = :p0", schedule_id) return rows or None await _until(fired, timeout=15, what="定时触发") await asyncio.sleep(3) # 再等几拍:不会再发一次 fires = await _db("SELECT kind, status, run_id FROM schedule_fires WHERE schedule_id = :p0", schedule_id) runs = await _db("SELECT id FROM runs WHERE schedule_id = :p0", schedule_id) assert len(fires) == 1 and len(runs) == 1, (fires, runs) await client.patch(f"{a.base}/api/schedules/{schedule_id}", json={"enabled": False}) async def test_5_chat_goes_before_queued_evaluation_runs(workers, monkeypatch): """全站 1 个名额:评测的作答运行(系统类)排着两个,问数据的运行(交互类)后来,先拿到名额。""" from app import cluster from app.core.config import settings from app.cluster import slots from app.cluster.relay import relay from app.db.base import SessionLocal from app.db.models import Run from app.engine.runner import run_manager a = workers["A"] async with await _client() as client: put = await client.put(f"{a.base}/api/settings", json={"values": {"concurrency": { "max_total": 1, "classes": {"interactive": {"limit": 1, "priority": 40}, "system": {"limit": 1, "priority": 10}}}}}) assert put.status_code == 200, put.text slots.forget_policy() monkeypatch.setattr(settings, "sandbox_backend", "local") await cluster.start() # 本测试进程登记成第三个工作进程,替评测发起作答运行 await relay.start() await run_manager.setup() try: evals = [await run_manager.start(graph=slow_graph(1.5), input_payload={"question": f"评测 {i}"}, origin_kind="eval", origin={"kind": "eval"}) for i in range(3)] async def first_running() -> Any: async with SessionLocal() as session: return (await session.get(Run, evals[0].id)).status == "running" await _until(first_running, what="第一题的作答运行拿到名额") await asyncio.sleep(0.3) chat_id = (await client.post(f"{a.base}/api/runs", json={ "graph": slow_graph(0.2), "input": {"question": "问数据"}, "origin": "chat"})).json()["id"] async def all_done() -> Any: async with SessionLocal() as session: rows = [await session.get(Run, rid, populate_existing=True) for rid in [*[e.id for e in evals], chat_id]] return rows if all(r.status in ("succeeded", "failed") for r in rows) else None rows = await _until(all_done, timeout=60, what="都跑完") assert all(r.status == "succeeded" for r in rows), [(r.id, r.status, r.error) for r in rows] e1, e2, e3, chat = rows assert e2.created_at < chat.created_at and e3.created_at < chat.created_at, "评测的两题先来、排着队" assert e1.started_at < chat.started_at < e2.started_at < e3.started_at, \ [(r.origin_kind, r.created_at, r.started_at) for r in rows] finally: await run_manager.shutdown() await relay.stop() await cluster.stop() await client.put(f"{a.base}/api/settings", json={"values": {"concurrency": slots.defaults()}}) async def test_5b_the_interactive_reserve_is_there_when_evaluations_fill_the_rest(workers, monkeypatch): """全站 2 个名额、留底 1 个:评测(系统类)只借得到 1 个,第二题排着;问数据一来马上拿到留底,评测的第一题还在跑。""" from app import cluster from app.cluster import slots from app.cluster.relay import relay from app.core.config import settings from app.db.base import SessionLocal from app.db.models import Run from app.engine.runner import run_manager a = workers["A"] async with await _client() as client: put = await client.put(f"{a.base}/api/settings", json={"values": {"concurrency": { "max_total": 2, "interactive_reserve": 1, "classes": {}}}}) assert put.status_code == 200, put.text got = put.json()["concurrency"] assert got["max_total"] - got["interactive_reserve"] == 1, "别的类最多借得到 全站 − 留底 = 1 个" slots.forget_policy() monkeypatch.setattr(settings, "sandbox_backend", "local") await cluster.start() await relay.start() await run_manager.setup() try: evals = [await run_manager.start(graph=slow_graph(3), input_payload={"question": f"评测 {i}"}, origin_kind="eval", origin={"kind": "eval"}) for i in range(2)] async def first_running() -> Any: async with SessionLocal() as session: return (await session.get(Run, evals[0].id)).status == "running" await _until(first_running, what="第一题的作答运行拿到名额") chat_id = (await client.post(f"{a.base}/api/runs", json={ "graph": slow_graph(0.2), "input": {"question": "问数据"}, "origin": "chat"})).json()["id"] async def all_done() -> Any: async with SessionLocal() as session: rows = [await session.get(Run, rid, populate_existing=True) for rid in [*[e.id for e in evals], chat_id]] return rows if all(r.status in ("succeeded", "failed") for r in rows) else None e1, e2, chat = await _until(all_done, timeout=60, what="都跑完") assert chat.started_at < e1.finished_at, "问数据拿到的是留底:评测的第一题还没跑完它就开始了" assert e2.started_at >= e1.finished_at, "评测的第二题借不到留底,等第一题跑完" finally: await run_manager.shutdown() await relay.stop() await cluster.stop() await client.put(f"{a.base}/api/settings", json={"values": {"concurrency": slots.defaults()}}) def test_6_the_daily_cap_holds_across_processes(workers): """两个进程同时往评测那本账上各记 20 次、每次 0.1 美元,上限 1 美元:合计记下的正好 10 次、不超过上限。""" script = ( "import asyncio, json\n" "from app.engine.spend import Ledger\n" "async def main():\n" " book = Ledger('e2e_spend')\n" " got = await asyncio.gather(*(book.charge(0.1, limit=1.0, priced=True) for _ in range(20)))\n" " print(json.dumps(sum(1 for d in got if d)))\n" "asyncio.run(main())\n") env = {**_worker_env(), "AGENTLAB_SCHEDULER": "0"} procs = [subprocess.Popen([sys.executable, "-c", script], cwd=str(BACKEND), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) for _ in range(2)] outs = [p.communicate(timeout=120) for p in procs] assert all(p.returncode == 0 for p in procs), [o[1].decode()[-2000:] for o in outs] charged = sum(int(o[0].decode().strip().splitlines()[-1]) for o in outs) assert charged == 10, f"两个进程合计记下 {charged} 次(上限只容得下 10 次)" total = asyncio.run(_spent()) assert total <= 1.0 + 1e-9, total async def _spent() -> float: from app.engine.spend import Ledger return float((await Ledger("e2e_spend").daily())["usd"]) async def _stall_a_during_a_run(client, a: Worker, b: Worker, seconds: float) -> str: """A 上发起一次运行,跑起来之后 SIGSTOP A,等 B 接手(标中断)。交回运行 id(A 还停着,调用方负责 SIGCONT)。""" run_id = (await client.post(f"{a.base}/api/runs", json={"graph": slow_graph(seconds), "input": {"question": "q"}, "origin": "canvas"})).json()["id"] await _until(lambda: _owner_while_active(run_id), what="A 认领了运行") await asyncio.sleep(0.5) a.pause() async def taken_over() -> Any: row = await _run(client, b, run_id) return row if row["status"] == "interrupted" else None await _until(taken_over, timeout=LEASE_S * 6 + 10, what="B 接手了卡住的 A 上的运行") return run_id async def _a_reidentified(client, a: Worker) -> None: """A 缓过来之后换了身份重新登记:fixture 里记的 owner 跟着换。""" old = a.owner async def fresh() -> Any: try: me = (await client.get(f"{a.base}/api/system")).json()["cluster"]["me"] except Exception: # noqa: BLE001 return None return me if me != old else None a.owner = await _until(fresh, timeout=15, what="A 换身份重新登记") async def _settled_events(client, worker: Worker, run_id: str) -> list[dict[str, Any]]: return (await client.get(f"{worker.base}/api/runs/{run_id}/events")).json() def _seqs_unique(events: list[dict[str, Any]]) -> None: seqs = [e["seq"] for e in events] assert len(seqs) == len(set(seqs)), f"撞号:{sorted(seqs)}" async def test_6b_a_stalled_worker_does_not_write_after_it_was_taken_over(workers): a, b = workers["A"], workers["B"] async with await _client() as client: try: run_id = await _stall_a_during_a_run(client, a, b, 6) finally: a.resume() await _a_reidentified(client, a) await asyncio.sleep(max(4.0, 3 * HEARTBEAT_S)) # A 手上那一步本来再过几秒就跑完:给它机会(它不该写) row = await _run(client, b, run_id) assert row["status"] == "interrupted", f"接手之后的状态被盖掉了:{row['status']}" events = await _settled_events(client, b, run_id) assert not [e for e in events if e["type"] in ("run.finished", "run.failed", "run.cancelled")] assert any(e["type"] == "log" and e["data"].get("code") == "server_shutdown" for e in events) _seqs_unique(events) async def test_6c_a_run_continued_by_the_other_worker_is_not_written_twice(workers): a, b = workers["A"], workers["B"] async with await _client() as client: try: run_id = await _stall_a_during_a_run(client, a, b, 3) cont = await client.post(f"{b.base}/api/runs/{run_id}/continue", json={}) assert cont.status_code == 200, cont.text finally: a.resume() await _a_reidentified(client, a) async def done() -> Any: row = await _run(client, b, run_id) return row if row["status"] in ("succeeded", "failed") and row.get("manifest_hash") else None row = await _until(done, timeout=60, what="B 接着跑完") await asyncio.sleep(2.0) assert row["status"] == "succeeded", row assert (await _run(client, b, run_id))["status"] == "succeeded", "A 缓过来之后没有把终态改掉" events = await _settled_events(client, b, run_id) assert len([e for e in events if e["type"] == "run.finished"]) == 1 _seqs_unique(events) verified = (await client.get(f"{b.base}/api/runs/{run_id}/verify")).json() assert verified["ok"] is True, verified async def test_7_kill_a_worker(workers): a, b = workers["A"], workers["B"] async with await _client() as client: leader_before = (await client.get(f"{a.base}/api/system")).json()["cluster"]["leader"]["owner"] # B 上:一次在跑(会被照实标中断),一次停在审批上(不属于任何进程,B 没了照样能在 A 上批) busy = (await client.post(f"{b.base}/api/runs", json={"graph": slow_graph(30), "input": {"question": "q"}, "origin": "canvas"})).json()["id"] paused = (await client.post(f"{b.base}/api/runs", json={"graph": APPROVAL_GRAPH, "input": {"question": "q"}, "origin": "canvas"})).json()["id"] await _until(lambda: _owner_while_active(busy), what="B 认领了运行") async def waiting() -> Any: return (await _run(client, a, paused))["status"] == "interrupted" await _until(waiting, what="停在审批上") b.kill() t0 = time.monotonic() async def taken_over() -> Any: row = await _run(client, a, busy) return row if row["status"] == "interrupted" else None row = await _until(taken_over, timeout=LEASE_S * 6 + 10, what="租约过期后被接手") assert "可从断点继续运行" in (row["error"] or "") events = (await client.get(f"{a.base}/api/runs/{busy}/events")).json() assert any(e["type"] == "log" and e["data"].get("code") == "server_shutdown" for e in events) print(f"强杀 B 之后 {time.monotonic() - t0:.1f} 秒被接手") info = (await client.get(f"{a.base}/api/system")).json()["cluster"] assert b.owner not in {w["id"] for w in info["workers"] if w["alive"]} if leader_before == b.owner: async def a_leads() -> Any: got = (await client.get(f"{a.base}/api/system")).json()["cluster"]["leader"] return got["owner"] == a.owner await _until(a_leads, timeout=LEASE_S * 6, what="A 接班") # 停在审批上的那一次:在 A 上批,接着跑完 ok = await client.post(f"{a.base}/api/runs/{paused}/resume", json={"response": {"approved": True}}) assert ok.status_code == 200, ok.text async def done() -> Any: return (await _run(client, a, paused))["status"] == "succeeded" await _until(done, timeout=60, what="批了之后在 A 上跑完") # 被标中断的那一次:在 A 上能接着跑(这里接上就停掉,不等它睡完) cont = await client.post(f"{a.base}/api/runs/{busy}/continue", json={}) assert cont.status_code == 200, cont.text await client.post(f"{a.base}/api/runs/{busy}/cancel")