Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

195 lines
6.5 KiB
Python

"""Run the live-LLM turn scenario suite sharded across local processes.
This mirrors the CI ``turn-live`` job
(``.github/workflows/interactive-shell-live.yml``): each shard sets
``TURN_SHARD_TOTAL`` / ``TURN_SHARD_INDEX`` and runs the live pytest selection
``-m live_llm -k "test_live_turn_execution_oracle or test_live_action_planning"``
against ``tests/core/agent/test_turn_scenarios.py``.
The suite is IO-bound (it waits on real LLM API calls), so running all shards
concurrently finishes in roughly one shard's wall time instead of the serial
total. Each shard runs as its own pytest process; per-shard output is streamed
to a log file and the exit codes are aggregated into a final summary.
Usage:
uv run python .github/ci/run_live_turn_shards.py # all 8 shards
uv run python .github/ci/run_live_turn_shards.py --shards 4
uv run python .github/ci/run_live_turn_shards.py --indexes 0,3
uv run python .github/ci/run_live_turn_shards.py -- -x # extra pytest args
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import IO
_TARGET = "tests/core/agent/test_turn_scenarios.py"
_K_EXPR = "test_live_turn_execution_oracle or test_live_action_planning"
_LOG_DIR = Path(".turn-shard-logs")
@dataclass(frozen=True)
class ShardResult:
index: int
exit_code: int
duration_s: float
log_path: Path
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--shards",
type=int,
default=int(os.getenv("TURN_SHARD_TOTAL", "8")),
help="Total number of shards to split the suite into (default: 8).",
)
parser.add_argument(
"--indexes",
type=str,
default="",
help="Comma-separated subset of shard indexes to run (default: all).",
)
parser.add_argument(
"--workers-per-shard",
type=str,
default="auto",
help="pytest-xdist worker count per shard, passed to -n (default: auto).",
)
parser.add_argument(
"--provider",
type=str,
default=os.getenv("LLM_PROVIDER", "openai"),
help="LLM provider for the live run (default: $LLM_PROVIDER or openai).",
)
parser.add_argument(
"pytest_args",
nargs="*",
help="Extra args forwarded to each pytest shard (after a -- separator).",
)
return parser.parse_args(argv)
def _resolve_indexes(shards: int, indexes: str) -> list[int]:
if shards < 1:
raise SystemExit("--shards must be >= 1")
if not indexes.strip():
return list(range(shards))
selected = sorted({int(part) for part in indexes.split(",") if part.strip()})
out_of_range = [i for i in selected if i < 0 or i >= shards]
if out_of_range:
raise SystemExit(f"shard indexes out of range for --shards {shards}: {out_of_range}")
return selected
def _build_command(workers: str, pytest_args: list[str]) -> list[str]:
return [
sys.executable,
"-m",
"pytest",
"-n",
workers,
"-v",
"-m",
"live_llm",
_TARGET,
"-k",
_K_EXPR,
*pytest_args,
]
def _shard_env(*, shard_total: int, shard_index: int, provider: str) -> dict[str, str]:
env = os.environ.copy()
env["TURN_SHARD_TOTAL"] = str(shard_total)
env["TURN_SHARD_INDEX"] = str(shard_index)
env["LLM_PROVIDER"] = provider
env.setdefault("OPENSRE_DISABLE_KEYRING", "1")
env.setdefault("PYTHONUTF8", "1")
return env
def _run_shards(
*, shard_total: int, indexes: list[int], workers: str, provider: str, pytest_args: list[str]
) -> list[ShardResult]:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
command = _build_command(workers, pytest_args)
print(f"Launching {len(indexes)} shard(s) of {shard_total} (provider={provider}):")
print(f" {' '.join(command)}\n")
started: dict[int, tuple[subprocess.Popen[bytes], float, Path, IO[bytes]]] = {}
for shard_index in indexes:
log_path = _LOG_DIR / f"shard-{shard_index}.log"
log_handle: IO[bytes] = log_path.open("wb")
process = subprocess.Popen(
command,
env=_shard_env(shard_total=shard_total, shard_index=shard_index, provider=provider),
stdout=log_handle,
stderr=subprocess.STDOUT,
)
started[shard_index] = (process, time.monotonic(), log_path, log_handle)
print(f" shard {shard_index} -> pid {process.pid}, log {log_path}")
print("\nWaiting for shards to finish...\n")
results: list[ShardResult] = []
pending = set(started)
while pending:
for shard_index in sorted(pending):
process, start_time, log_path, log_handle = started[shard_index]
code = process.poll()
if code is None:
continue
log_handle.close()
duration = time.monotonic() - start_time
status = "PASS" if code == 0 else f"FAIL (exit {code})"
print(f" shard {shard_index} {status} in {duration:.0f}s")
results.append(
ShardResult(
index=shard_index,
exit_code=code,
duration_s=duration,
log_path=log_path,
)
)
pending.discard(shard_index)
if pending:
time.sleep(2.0)
return sorted(results, key=lambda r: r.index)
def _print_summary(results: list[ShardResult]) -> int:
failures = [r for r in results if r.exit_code != 0]
print("\n==================== live turn shard summary ====================")
for result in results:
status = "PASS" if result.exit_code == 0 else f"FAIL (exit {result.exit_code})"
print(f" shard {result.index}: {status} [{result.duration_s:.0f}s] {result.log_path}")
if failures:
print(f"\n{len(failures)} shard(s) failed. Inspect the logs above.")
return 1
print("\nAll shards passed.")
return 0
def main(argv: list[str] | None = None) -> int:
args = _parse_args(sys.argv[1:] if argv is None else argv)
indexes = _resolve_indexes(args.shards, args.indexes)
results = _run_shards(
shard_total=args.shards,
indexes=indexes,
workers=args.workers_per_shard,
provider=args.provider,
pytest_args=list(args.pytest_args),
)
return _print_summary(results)
if __name__ == "__main__":
raise SystemExit(main())