diff --git a/.github/workflows/benchmark-dispatch.yml b/.github/workflows/benchmark-dispatch.yml index ec9bb7db930..268785b71d3 100644 --- a/.github/workflows/benchmark-dispatch.yml +++ b/.github/workflows/benchmark-dispatch.yml @@ -1,6 +1,8 @@ name: 'Dispatch SWE-bench Verified Benchmark' on: + release: + types: [published] workflow_dispatch: inputs: qwen_ref: @@ -20,37 +22,62 @@ on: jobs: dispatch: name: 'Submit benchmark run' + if: '${{ github.event_name != ''release'' || github.event.release.prerelease == false }}' runs-on: [self-hosted, Linux, X64, qwen-benchmark] timeout-minutes: 10 permissions: contents: 'read' steps: - - name: 'Checkout benchmarked release' - uses: 'actions/checkout@v4' - with: - ref: '${{ github.event.release.tag_name || inputs.qwen_ref }}' - fetch-depth: 1 - - name: 'Resolve immutable release commit' id: 'revision' shell: 'bash' - run: 'echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"' + env: + QWEN_REF: '${{ github.event.release.tag_name || inputs.qwen_ref }}' + GH_TOKEN: '${{ github.token }}' + run: |- + set -euo pipefail + object="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/tags/${QWEN_REF}")" + object_type="$(jq -r '.object.type' <<<"${object}")" + object_sha="$(jq -r '.object.sha' <<<"${object}")" + while [[ "${object_type}" == 'tag' ]]; do + object="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/tags/${object_sha}")" + object_type="$(jq -r '.object.type' <<<"${object}")" + object_sha="$(jq -r '.object.sha' <<<"${object}")" + done + [[ "${object_type}" == 'commit' && "${object_sha}" =~ ^[0-9a-f]{40}$ ]] + echo "commit=${object_sha}" >> "${GITHUB_OUTPUT}" - - name: 'Submit benchmark request to local ECS service' + - name: 'Submit benchmark request to local ECS queue' env: QWEN_REF: '${{ github.event.release.tag_name || inputs.qwen_ref }}' QWEN_COMMIT: '${{ steps.revision.outputs.commit }}' - SUITE: '${{ inputs.suite }}' + SUITE: "${{ inputs.suite || 'swebench_verified_harbor_smoke' }}" + RELEASE_ID: '${{ github.event.release.id }}' + TRIGGER: '${{ github.event_name }}' run: |- set -euo pipefail - response="$(sudo -n /usr/local/sbin/qwen-benchmark-dispatch \ - --repository "${GITHUB_REPOSITORY}" \ - --qwen-ref "${QWEN_REF}" \ - --qwen-commit "${QWEN_COMMIT}" \ - --suite "${SUITE}" \ - --github-run-id "${GITHUB_RUN_ID}" \ - --github-run-attempt "${GITHUB_RUN_ATTEMPT}")" + args=( + --repository "${GITHUB_REPOSITORY}" + --qwen-ref "${QWEN_REF}" + --qwen-commit "${QWEN_COMMIT}" + --suite "${SUITE}" + --trigger "${TRIGGER}" + --github-run-id "${GITHUB_RUN_ID}" + --github-run-attempt "${GITHUB_RUN_ATTEMPT}" + ) + if [[ -n "${RELEASE_ID}" ]]; then + args+=(--release-id "${RELEASE_ID}") + fi + response="$(sudo -n /usr/local/sbin/qwen-benchmark-dispatch "${args[@]}")" echo "${response}" | jq . echo "### Benchmark queued" >> "$GITHUB_STEP_SUMMARY" echo "${response}" | jq -r '"- Run ID: `" + .run_id + "`\n- Status: `" + .status + "`"' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e1a08b5eb40..f18c1bbb72f 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -26,6 +26,10 @@ on: options: - 'swebench_verified_gold_smoke' - 'swebench_verified_harbor_smoke' + release_id: + description: 'Optional GitHub Release ID to update after the benchmark.' + required: false + type: string jobs: stale: @@ -75,31 +79,55 @@ jobs: contents: read steps: - - name: 'Checkout benchmarked release' - uses: 'actions/checkout@v4' - with: - ref: '${{ inputs.qwen_ref }}' - fetch-depth: 1 - - name: 'Resolve immutable release commit' id: revision shell: bash - run: 'echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"' + env: + QWEN_REF: '${{ inputs.qwen_ref }}' + GH_TOKEN: '${{ github.token }}' + run: |- + set -euo pipefail + object="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/tags/${QWEN_REF}")" + object_type="$(jq -r '.object.type' <<<"${object}")" + object_sha="$(jq -r '.object.sha' <<<"${object}")" + while [[ "${object_type}" == 'tag' ]]; do + object="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/tags/${object_sha}")" + object_type="$(jq -r '.object.type' <<<"${object}")" + object_sha="$(jq -r '.object.sha' <<<"${object}")" + done + [[ "${object_type}" == 'commit' && "${object_sha}" =~ ^[0-9a-f]{40}$ ]] + echo "commit=${object_sha}" >> "${GITHUB_OUTPUT}" - - name: 'Queue benchmark on local ECS service' + - name: 'Queue benchmark in local ECS worker' env: QWEN_REF: '${{ inputs.qwen_ref }}' QWEN_COMMIT: '${{ steps.revision.outputs.commit }}' SUITE: '${{ inputs.suite }}' + RELEASE_ID: '${{ inputs.release_id }}' + TRIGGER: workflow_dispatch run: |- set -euo pipefail - response="$(sudo -n /usr/local/sbin/qwen-benchmark-dispatch \ - --repository "${GITHUB_REPOSITORY}" \ - --qwen-ref "${QWEN_REF}" \ - --qwen-commit "${QWEN_COMMIT}" \ - --suite "${SUITE}" \ - --github-run-id "${GITHUB_RUN_ID}" \ - --github-run-attempt "${GITHUB_RUN_ATTEMPT}")" + args=( + --repository "${GITHUB_REPOSITORY}" + --qwen-ref "${QWEN_REF}" + --qwen-commit "${QWEN_COMMIT}" + --suite "${SUITE}" + --trigger "${TRIGGER}" + --github-run-id "${GITHUB_RUN_ID}" + --github-run-attempt "${GITHUB_RUN_ATTEMPT}" + ) + if [[ -n "${RELEASE_ID}" ]]; then + args+=(--release-id "${RELEASE_ID}") + fi + response="$(sudo -n /usr/local/sbin/qwen-benchmark-dispatch "${args[@]}")" echo "${response}" | jq . echo "### Benchmark queued" >> "$GITHUB_STEP_SUMMARY" echo "${response}" | jq -r '"- Run ID: `" + .run_id + "`\n- Status: `" + .status + "`"' >> "$GITHUB_STEP_SUMMARY" diff --git a/benchmark-service/README.md b/benchmark-service/README.md index 848e380536a..bf68b4c521e 100644 --- a/benchmark-service/README.md +++ b/benchmark-service/README.md @@ -1,114 +1,526 @@ -# Qwen Code benchmark service +# Qwen Code Benchmark Worker -Single-node control plane for the SWE-bench Verified POC. It accepts an -allowlisted suite, persists the run in SQLite, evaluates it asynchronously, and -writes the run evidence to the configured artifact root. +Qwen Code 发版后 Benchmark 的单机 ECS 执行服务。当前 POC 已打通: -## Local development +```text +GitHub Release / workflow_dispatch + -> GitHub Actions self-hosted runner + -> 本机 dispatcher + -> qwen-benchmark-submit CLI + -> SQLite + -> systemd worker + -> Harbor + Docker + Qwen Code Agent + verifier + -> GitHub Release benchmark summary +``` + +本仓库只包含 Benchmark submit CLI、worker、Harbor 适配、状态存储、部署模板和测试,不包含 Qwen Code 源码、模型密钥、GitHub PAT、运行数据库、trajectory 或历史评测产物。 + +## 1. 设计目标 + +- 将 Qwen Code Release 与 SWE-bench 类 Benchmark 串成可重复的流水线。 +- 将 Release tag、npm package、实际 Agent 版本和不可变 commit 对齐。 +- GitHub Actions 只负责派发,不占用 runner 等待数小时。 +- ECS worker 独立维护任务状态、heartbeat、retry、产物和终态回写。 +- 成功任务公开有效分数;失败任务只公开 failed,不发布误导性分数。 +- raw trajectory、模型密钥、内部日志和运行环境信息始终留在 ECS/私有 OSS。 + +当前实现定位为单机 POC,不是最终的 50 并发生产调度器。 + +## 2. 核心组件 + +### Submit CLI + +入口为 `qwen-benchmark-submit`。GitHub self-hosted runner 与 worker 位于同一台 ECS,因此任务不经过 HTTP 服务,CLI 直接校验参数并写入本机 SQLite。 + +CLI 负责: + +- 校验仓库、Release ref、commit 和 trigger。 +- 校验 suite allowlist。 +- 生成或接受 idempotency key。 +- 初始化 SQLite schema。 +- 创建 run 和 instance manifest。 +- 返回 `run_id`、初始状态和是否命中幂等记录。 + +相同 idempotency key 不会创建重复任务。CLI 不读取模型 key 或 GitHub token,也不需要 shared token。 + +### SQLite 状态库 + +SQLite 使用 WAL 模式,保存: + +- `runs`:版本、suite、run 状态、attempt、heartbeat 和汇总结果。 +- `instances`:每个 case 的状态。 +- `events`:状态变化及恢复事件。 + +SQLite 是 POC 的任务队列和状态库。当前不依赖 RocketMQ、Kafka 或 Redis。 + +### Worker + +`qwen-benchmark-worker` 是常驻 systemd 服务: + +1. 按创建时间领取一个 `QUEUED` run。 +2. 冻结 suite 和实例清单。 +3. 选择 gold、原生 SWE-bench 或 Harbor runner。 +4. 更新 heartbeat 和实例状态。 +5. 收集 grader、Harbor、trajectory 和汇总产物。 +6. 将 run 转为唯一终态。 +7. 成功或终态失败后回写 GitHub。 + +worker 重启时会检查中断任务。基础设施型中断在 attempt 未耗尽时重新入队,否则标记为 `FAILED`。 + +### Harbor runner + +Harbor 模式执行: + +```text +harbor run + --dataset + --include-task-name + --agent qwen-coder + --model + --agent-kwarg version= + --env docker +``` + +运行前会确认 `@qwen-code/qwen-code@` 已在 npm registry 可见,并在结果解析阶段校验 Harbor 实际记录的 Qwen Code Agent 版本。 + +每个 case 使用独立 Docker testbed。Docker 镜像可以共享节点缓存,但运行容器和任务产物互相隔离。 + +### GitHub publisher + +终态回写包括: + +- 更新触发该任务的 GitHub Release。 + +成功结果公开: + +- dataset 和 revision +- suite 和评测方式 +- expected/completed case 数 +- resolved/unresolved/infra error 数 +- 百分比分数 +- Qwen Code 版本和 commit +- ECS run ID + +失败结果只显示 `Failed — not scored`,不发布数值分数。Release 中使用固定 marker,重跑会替换旧结果,不会无限追加。 + +## 3. 执行状态 + +主状态流: + +```text +QUEUED + -> PREPARING + -> RUNNING_AGENT + -> GRADING + -> UPLOADING + -> SUCCEEDED +``` + +异常终态: + +```text +FAILED +CANCELED +``` + +只有 manifest 中全部预期实例都得到终态、结果数量校验通过,run 才能进入 `SUCCEEDED`。 + +## 4. Retry 边界 + +自动 retry 只用于基础设施问题,例如: + +- worker 进程或节点异常退出 +- Docker/Harbor 启动失败且没有产生有效 trial +- npm/网络等外部依赖暂时不可用 +- grader 未返回完整 manifest + +以下结果不自动 retry: + +- Agent 正常完成但没有解决问题 +- verifier 判定未通过 +- 正常达到任务 timeout +- 有效的 unresolved 结果 + +POC 默认 `max_attempts=2`,即首次执行加一次基础设施重试。 + +## 5. 当前并发模型 + +当前 ECS POC 是串行执行: + +- 一个 systemd worker 进程。 +- worker 同时领取一个 run。 +- Harbor runner 逐个遍历 suite 中的 case。 +- 每个 case 单独启动一次 `harbor run`。 +- Harbor 参数为 `--n-concurrent 1`。 + +因此当前不是一个 Harbor 管理全部并发任务,也不是多个 Harbor 同时运行。 + +生产扩展建议由外层调度器控制全局并发,将数据集切成 shard;每个 shard 启动独立 Harbor 进程,并在 Harbor 内使用小规模 `--n-concurrent`。例如: + +```text +10 个 shard x 每个 Harbor 并发 5 = 全局 50 case +``` + +不建议单个 Harbor 进程管理全部 500 个 case,否则单进程失败、超时和 retry 的影响范围过大。 + +## 6. 内置 suites + +suite 定义在 `qwen_benchmark/suites.json`。 + +### `swebench_verified_gold_smoke` + +- 使用 SWE-bench Verified gold patch。 +- 不消耗模型 token。 +- 用于验证 Docker、数据集、grader、SQLite 和产物链路。 +- 结果不能作为 Qwen Code Agent 分数。 + +### `swebench_verified_qwen_smoke` + +- 使用本地 Qwen Code 源码和原生 SWE-bench harness。 +- 通过独立 git worktree 固定到目标 commit。 + +### `swebench_verified_harbor_smoke` + +- 使用开源 Harbor Framework。 +- 从 Release tag 推导并安装对应 Qwen Code npm 版本。 +- 使用 `qwen-coder` Agent 和 Docker 环境。 +- 当前包含 `sympy__sympy-20590` 单 case。 + +扩展正式 suite 时应固定: + +- dataset 和 revision +- instance manifest +- Qwen Code release/commit +- Agent/Harbor 配置 +- model +- timeout、最大 turns 和并发度 + +## 7. 目录结构 + +```text +qwen_benchmark/ + submit.py 本机幂等任务提交 CLI + config.py 环境配置和 suite 类型 + store.py SQLite、状态机和恢复 + worker.py 常驻 worker 和终态处理 + harbor_runner.py Harbor/Qwen Code npm 版本适配 + runner.py gold 和原生 SWE-bench runner + publisher.py GitHub Release/Check 回写 + artifacts.py 产物收集和 checksum + suites.json allowlisted suite manifest + +deploy/ + systemd/ ECS worker systemd unit + qwen-benchmark-dispatch + self-hosted runner 本地派发脚本 + qwen-benchmark-dispatch.sudoers + 最小 sudo 权限 + benchmark.ecs.env ECS 非敏感配置模板 + benchmark.env.example + 通用配置模板 +tests/ submit、worker、Harbor 和 publisher 测试 +``` + +## 8. 本地开发 + +要求: + +- Python 3.12+ +- Docker +- git +- `jq` 和 `curl` +- 运行 Harbor suite 时安装 Harbor Framework + +创建环境: ```bash -cd benchmark-service python3.12 -m venv .venv .venv/bin/pip install -e '.[test]' +``` + +运行测试: + +```bash +PYTHONPATH=. .venv/bin/pytest -q +``` +当前代码基线预期: + +```text +12 passed +``` + +本地启动: + +```bash export BENCHMARK_ROOT="$PWD/.state" export BENCHMARK_DATABASE_PATH="$PWD/.state/benchmark.db" export BENCHMARK_WORK_ROOT="$PWD/.work" export BENCHMARK_ARTIFACT_ROOT="$PWD/.artifacts" -export BENCHMARK_QWEN_REPO="$(git rev-parse --show-toplevel)" +export BENCHMARK_QWEN_REPO=/path/to/qwen-code export BENCHMARK_SWEBENCH_PYTHON="$PWD/.venv/bin/python" -export BENCHMARK_AUTH_MODE=token -export BENCHMARK_SHARED_TOKEN=development-only +.venv/bin/qwen-benchmark-worker +``` + +另一个终端可直接提交 smoke run: + +```bash +.venv/bin/qwen-benchmark-submit \ + --repository QwenLM/qwen-code \ + --qwen-ref v0.20.0 \ + --qwen-commit 0123456789abcdef0123456789abcdef01234567 \ + --suite swebench_verified_gold_smoke \ + --trigger manual \ + --idempotency-key local-gold-smoke-1 +``` + +## 9. ECS 部署 + +推荐目录: + +```text +/srv/qwen-benchmark/ + config/ + benchmark.env + benchmark.secret.env + state/ + benchmark.db + artifacts/ + workspaces/ + harbor/jobs/ + cache/ + src/ + qwen-code/ + qwen-code-benchmark-worker/ + venv/ +``` + +安装: + +```bash +sudo mkdir -p /srv/qwen-benchmark/{config,state,artifacts,workspaces,harbor/jobs,cache,src} +sudo chown -R ecs-user:ecs-user /srv/qwen-benchmark + +python3.12 -m venv /srv/qwen-benchmark/venv +/srv/qwen-benchmark/venv/bin/pip install -e '/srv/qwen-benchmark/src/qwen-code-benchmark-worker[test]' +``` -qwen-benchmark-api -qwen-benchmark-worker +复制配置: + +```bash +sudo install -o root -g root -m 0644 \ + deploy/benchmark.ecs.env \ + /srv/qwen-benchmark/config/benchmark.env + +sudo install -o root -g root -m 0600 /dev/null \ + /srv/qwen-benchmark/config/benchmark.secret.env +``` + +`benchmark.secret.env` 仅供 worker 读取,按需要设置: + +```text +BENCHMARK_GITHUB_TOKEN= ``` -Submit the infrastructure smoke suite: +模型凭证建议使用独立 root-only EnvironmentFile,不要写入仓库或普通配置模板。 + +安装 worker 服务: ```bash -curl --fail-with-body \ - -X POST http://127.0.0.1:8000/api/v1/runs \ - -H 'Authorization: Bearer development-only' \ - -H 'Content-Type: application/json' \ - -H 'Idempotency-Key: local-gold-smoke-1' \ - -d '{ - "repository": "QwenLM/qwen-code", - "qwen_ref": "HEAD", - "suite": "swebench_verified_gold_smoke", - "trigger": "manual" - }' -``` - -The gold suite validates the service, Docker, official dataset, grader, SQLite, -and OSS without consuming model tokens. The Qwen suite additionally requires -`OPENAI_API_KEY`, `OPENAI_BASE_URL`, and `OPENAI_MODEL`. - -The production-shaped smoke suite is `swebench_verified_harbor_smoke`. It uses -the open-source Harbor Framework, pins the Qwen Code npm version from `qwen_ref` -(for example `v0.19.7`), and rejects a trial when Harbor reports a different -agent version. The release request should include the immutable 40-character -`qwen_commit` so the ECS does not need to fetch GitHub to resolve the tag. - -## Outbound-only GitHub Release pipeline - -No public API endpoint, domain, or inbound security-group rule is needed for the -single-node ECS POC. `qwen-benchmark-release-poller --once` retrieves the -latest non-draft, non-prerelease Release from `QwenLM/qwen-code` and creates an -idempotent run keyed by its GitHub `release.id`. The worker passes the Release -tag to Harbor, which waits for and installs the matching published -`@qwen-code/qwen-code` npm version. It rejects a trial that reports a different -Qwen Code version. - -Install `deploy/qwen-benchmark-release-poller.service` and -`deploy/qwen-benchmark-release-poller.timer` on ECS. The timer runs every five -minutes. On first enable it queues only the current newest stable Release, not -the repository's historical releases. - -Set `BENCHMARK_GITHUB_TOKEN` in the ECS-only secret file. Prefer a GitHub App -installation token limited to `QwenLM/qwen-code`; it needs Contents read/write -to read and update Releases and Checks write to create the completion Check -Run. On success, the worker updates the triggering Release body with only the -suite, version, commit, aggregate result, and run ID. Raw trajectories and -private artifacts remain on ECS/OSS. - -## Target deployment - -Install this package into `/mnt/workspace/qwen-benchmark/venv`, copy an edited -`deploy/benchmark.env.example` to -`/mnt/workspace/qwen-benchmark/config/benchmark.env`, install Supervisor, and -start it with: +sudo install -m 0644 deploy/systemd/qwen-benchmark-worker.service \ + /etc/systemd/system/qwen-benchmark-worker.service + +sudo systemctl daemon-reload +sudo systemctl enable --now qwen-benchmark-worker.service +``` + +安装 self-hosted runner dispatcher: + +这里的 `qwen-runner` 是 GitHub Actions runner 的系统用户;dispatcher +再将实际入队操作降权为 `ecs-user`。如果 runner 使用其他系统用户,需要同步修改 +`deploy/qwen-benchmark-dispatch.sudoers`,worker 用户仍保持 `ecs-user`。 ```bash -supervisord -c /mnt/workspace/qwen-benchmark/service/deploy/supervisord.conf +sudo install -o root -g root -m 0750 \ + deploy/qwen-benchmark-dispatch \ + /usr/local/sbin/qwen-benchmark-dispatch +sudo install -o root -g root -m 0440 \ + deploy/qwen-benchmark-dispatch.sudoers \ + /etc/sudoers.d/qwen-benchmark-dispatch +sudo visudo -cf /etc/sudoers.d/qwen-benchmark-dispatch ``` -Use shared-token mode only for a private POC route. The GitHub workflow expects -OIDC mode for a public HTTPS endpoint. +## 10. 配置项 + +常用非敏感配置: + +| 配置 | 说明 | +| --- | --- | +| `BENCHMARK_DATABASE_PATH` | SQLite 路径 | +| `BENCHMARK_WORK_ROOT` | 临时工作目录 | +| `BENCHMARK_ARTIFACT_ROOT` | 汇总产物根目录 | +| `BENCHMARK_QWEN_REPO` | 本机 Qwen Code repo/cache | +| `BENCHMARK_HARBOR_BINARY` | Harbor CLI 路径 | +| `BENCHMARK_HARBOR_JOBS_ROOT` | Harbor jobs 目录 | +| `BENCHMARK_NPM_REGISTRY` | Qwen Code npm 查询/安装 registry | +| `BENCHMARK_NPM_WAIT_SECONDS` | 等待 npm Release 可见的上限 | +| `BENCHMARK_POLL_SECONDS` | worker 空闲轮询间隔 | +| `BENCHMARK_ALLOWED_REPOSITORY` | 允许触发的 GitHub 仓库 | +| `OPENAI_BASE_URL` | 模型 API endpoint | +| `OPENAI_MODEL` | Benchmark 模型 | + +敏感配置: + +| 配置 | 说明 | +| --- | --- | +| `BENCHMARK_GITHUB_TOKEN` | GitHub Release API 写权限 | +| `OPENAI_API_KEY` | 模型 API key | + +GitHub token 最小权限: + +- Repository:仅 `QwenLM/qwen-code` +- Contents:read/write,用于读取和更新 Release + +正式环境优先使用 GitHub App installation token,不长期使用个人 PAT。 + +## 11. GitHub Release 派发 + +正式 workflow 建议: + +```text +release.published + -> 仅 stable Release 自动执行 + -> 通过 GitHub API 解析 tag commit + -> self-hosted runner 调用 qwen-benchmark-dispatch + -> submit CLI 写入 SQLite 后 Actions job 结束 +``` + +dispatcher 请求必须包含: + +- repository +- qwen_ref +- 40 字符 qwen_commit +- suite +- release_id +- GitHub run ID/attempt +- trigger 类型 + +示例: + +```bash +sudo -n /usr/local/sbin/qwen-benchmark-dispatch \ + --repository QwenLM/qwen-code \ + --qwen-ref v0.20.0 \ + --qwen-commit 0123456789abcdef0123456789abcdef01234567 \ + --suite swebench_verified_harbor_smoke \ + --release-id 123456789 \ + --trigger release \ + --github-run-id 1234567890 \ + --github-run-attempt 1 +``` + +## 12. 运行与排障 + +服务状态: + +```bash +systemctl status qwen-benchmark-worker.service +``` + +日志: + +```bash +journalctl -u qwen-benchmark-worker.service -f +``` + +检查 worker 进程和最近状态: + +```bash +systemctl is-active qwen-benchmark-worker.service +journalctl -u qwen-benchmark-worker.service --since '10 minutes ago' +``` + +单个 run 的主要证据: + +```text +// + request.json + manifest.json + status.json + summary.json + checksums.sha256 + grader/ + harbor/ + publisher-error.json # 仅发布失败时出现 +``` + +Harbor 原始任务目录: + +```text +//attempt-XX// +``` + +判断任务是否真正成功时,应同时检查数据库终态、manifest 数量、`summary.json`、grader/verifier 结果和 checksum,不能只看 GitHub Actions dispatch job 为 success。 + +## 13. 安全边界 + +禁止提交: + +- `benchmark.secret.env` +- `OPENAI_API_KEY` +- GitHub token/PAT +- SQLite 数据库 +- raw trajectory +- Harbor job 原始日志 +- 内网 endpoint 和未脱敏环境变量 + +建议公开: + +- 聚合分数 +- expected/completed/resolved/unresolved/infra error 数量 +- Qwen Code 版本和 commit +- dataset/revision/suite +- run ID 和评测方法 + +当前方案没有 FastAPI、HTTP listener 或公网入口,不需要开放 8000/443。若未来改为远程控制面,再单独设计 HTTPS/OIDC API。 + +当前 Harbor Docker backend 会将部分容器环境变量展开到 `docker compose exec` +的子进程参数中。即使凭证源文件是 root-only,具有主机进程查看权限的用户仍可能看到 +模型凭证。因此 POC ECS 应保持单租户、限制 SSH/sudo/进程查看权限,并在每轮测试后 +轮换模型凭证;生产化前应改为不在命令行展开 secret 的传递方式。 + +## 14. 已验证 POC -### Alibaba Cloud ECS +2026-07-22 使用以下配置完成端到端验证: -On a regular Ubuntu ECS host, install `deploy/benchmark.ecs.env` as -`/srv/qwen-benchmark/config/benchmark.env`, store the shared token separately in -`benchmark.secret.env` with mode `0600`, and install the two units from -`deploy/systemd/` under `/etc/systemd/system/`. The API binds only to loopback; -Nginx or an SSH-based dispatcher is the external entry point. +- Qwen Code:`0.20.0-nightly.20260722.b98306b7e` +- Commit:`77115af615fca031a57505dee07deeaf702a0937` +- Dataset:`swe-bench/swe-bench-verified@2` +- Suite:`swebench_verified_harbor_smoke` +- Case:`sympy__sympy-20590` +- Model:`qwen3.7-plus` +- 执行:1/1 completed +- 结果:1 resolved、0 unresolved、0 infra error +- Verifier reward:1.0 +- 耗时:约 6 分 27 秒 +- 测试:13 passed -If the host cannot reach Docker Hub directly, install -`deploy/docker-daemon.ecs.json` as `/etc/docker/daemon.json` and restart Docker. -Treat public mirrors as a POC dependency; use ACR replication for stable runs. +该结果仅证明单 case POC 链路和执行器可用,不能代表完整 SWE-bench Verified 分数。 -## Reverse proxy +## 15. 当前限制与下一步 -`deploy/nginx-http.conf` is an HTTP-only connectivity configuration. It keeps -FastAPI on `127.0.0.1:8000`, proxies `/healthz` and `/api/`, and rejects other -paths. Do not send credentials or GitHub OIDC tokens over this listener. +- 当前 worker 和 Harbor case 执行均为串行。 +- SQLite 适合单机 POC,不适合多节点高写并发。 +- publisher 当前为 best-effort,API 失败会记录 `publisher-error.json`,尚无持久化 outbox。 +- 多 case run 的系统失败阈值尚未确定。 +- 镜像依赖节点 Docker cache,正式环境应使用 ACR 复制或预拉取。 +- 完整 SWE-bench Verified 运行前需实测资源、并发、超时和预算。 -For production, copy `deploy/nginx-https.conf.template`, replace -`BENCHMARK_DOMAIN`, and install a trusted TLS certificate at the paths in the -template. Only TCP 443 should be public; TCP 8000 remains loopback-only. +生产化建议依次完成: -The DSW runtime already has a Supervisor instance. Install -`deploy/nginx-supervisor.conf` under -`/etc/dsw/sys_configs/supervisor/conf.d/`, then use that runtime's -`supervisorctl reread` and `supervisorctl update` commands to manage Nginx. +1. GitHub App 凭证和 publisher outbox。 +2. suite manifest 冻结及结果 schema 版本化。 +3. 数据集 shard 和全局并发调度。 +4. 多 worker/Kubernetes 资源池。 +5. 私有 OSS 原始产物与公共聚合结果分层。 +6. 完整版本历史汇总页面。 diff --git a/benchmark-service/deploy/benchmark.ecs.env b/benchmark-service/deploy/benchmark.ecs.env index 51874e01e99..26eb8a78f06 100644 --- a/benchmark-service/deploy/benchmark.ecs.env +++ b/benchmark-service/deploy/benchmark.ecs.env @@ -1,6 +1,3 @@ -BENCHMARK_AUTH_MODE=token -BENCHMARK_HOST=127.0.0.1 -BENCHMARK_PORT=8000 BENCHMARK_ROOT=/srv/qwen-benchmark BENCHMARK_DATABASE_PATH=/srv/qwen-benchmark/state/benchmark.db BENCHMARK_WORK_ROOT=/srv/qwen-benchmark/workspaces @@ -13,8 +10,6 @@ BENCHMARK_NPM_REGISTRY=https://registry.npmmirror.com BENCHMARK_NPM_WAIT_SECONDS=600 BENCHMARK_POLL_SECONDS=5 BENCHMARK_ALLOWED_REPOSITORY=QwenLM/qwen-code -# Outbound-only trigger: the timer polls the newest non-prerelease GitHub Release. -BENCHMARK_RELEASE_POLL_SUITE=swebench_verified_harbor_smoke XDG_CACHE_HOME=/srv/qwen-benchmark/cache NPM_CONFIG_CACHE=/srv/qwen-benchmark/cache/npm DOCKER_CONFIG=/srv/qwen-benchmark/cache/docker diff --git a/benchmark-service/deploy/benchmark.env.example b/benchmark-service/deploy/benchmark.env.example index 226350014b9..a7cfb03ec2f 100644 --- a/benchmark-service/deploy/benchmark.env.example +++ b/benchmark-service/deploy/benchmark.env.example @@ -1,7 +1,3 @@ -BENCHMARK_AUTH_MODE=token -BENCHMARK_SHARED_TOKEN=replace-with-a-random-secret -BENCHMARK_HOST=0.0.0.0 -BENCHMARK_PORT=8000 BENCHMARK_ROOT=/mnt/workspace/qwen-benchmark BENCHMARK_DATABASE_PATH=/mnt/workspace/qwen-benchmark/state/benchmark.db BENCHMARK_WORK_ROOT=/tmp/qwen-benchmark/workspaces @@ -10,12 +6,6 @@ BENCHMARK_QWEN_REPO=/mnt/workspace/qwen-benchmark/src/qwen-code BENCHMARK_SWEBENCH_PYTHON=/mnt/workspace/qwen-benchmark/venv/bin/python BENCHMARK_POLL_SECONDS=5 BENCHMARK_ALLOWED_REPOSITORY=QwenLM/qwen-code -# Outbound-only Release polling (no public API endpoint or domain required). -BENCHMARK_RELEASE_POLL_SUITE=swebench_verified_harbor_smoke -# Production OIDC mode also requires these values: -# BENCHMARK_AUTH_MODE=oidc -# BENCHMARK_ALLOWED_REPOSITORY_ID= -# BENCHMARK_ALLOWED_WORKFLOW=QwenLM/qwen-code/.github/workflows/benchmark-dispatch.yml@refs/heads/main # BENCHMARK_GITHUB_TOKEN= # OPENAI_API_KEY= # OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 diff --git a/benchmark-service/deploy/nginx-http.conf b/benchmark-service/deploy/nginx-http.conf deleted file mode 100644 index 7ebdf14bcaa..00000000000 --- a/benchmark-service/deploy/nginx-http.conf +++ /dev/null @@ -1,32 +0,0 @@ -server { - listen 80 default_server; - listen [::]:80 default_server; - server_name _; - - access_log /var/log/nginx/qwen-benchmark-access.log; - error_log /var/log/nginx/qwen-benchmark-error.log warn; - - client_max_body_size 64k; - - location = /healthz { - proxy_pass http://127.0.0.1:8000/healthz; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - location /api/ { - proxy_pass http://127.0.0.1:8000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_connect_timeout 5s; - proxy_read_timeout 65s; - proxy_send_timeout 65s; - } - - location / { - return 404; - } -} diff --git a/benchmark-service/deploy/nginx-https.conf.template b/benchmark-service/deploy/nginx-https.conf.template deleted file mode 100644 index 2ddc8dad691..00000000000 --- a/benchmark-service/deploy/nginx-https.conf.template +++ /dev/null @@ -1,43 +0,0 @@ -server { - listen 80 default_server; - listen [::]:80 default_server; - server_name BENCHMARK_DOMAIN; - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl; - listen [::]:443 ssl; - server_name BENCHMARK_DOMAIN; - - ssl_certificate /etc/letsencrypt/live/BENCHMARK_DOMAIN/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/BENCHMARK_DOMAIN/privkey.pem; - ssl_protocols TLSv1.2 TLSv1.3; - - access_log /var/log/nginx/qwen-benchmark-access.log; - error_log /var/log/nginx/qwen-benchmark-error.log warn; - - client_max_body_size 64k; - - location = /healthz { - proxy_pass http://127.0.0.1:8000/healthz; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto https; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - location /api/ { - proxy_pass http://127.0.0.1:8000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto https; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_connect_timeout 5s; - proxy_read_timeout 65s; - proxy_send_timeout 65s; - } - - location / { - return 404; - } -} diff --git a/benchmark-service/deploy/nginx-supervisor.conf b/benchmark-service/deploy/nginx-supervisor.conf deleted file mode 100644 index 6ac12c69660..00000000000 --- a/benchmark-service/deploy/nginx-supervisor.conf +++ /dev/null @@ -1,9 +0,0 @@ -[program:qwen-benchmark-nginx] -command=/usr/sbin/nginx -g "daemon off;" -autorestart=true -startsecs=3 -stopasgroup=true -killasgroup=true -stdout_logfile=/tmp/qwen-benchmark/logs/nginx-supervisor.log -stderr_logfile=/tmp/qwen-benchmark/logs/nginx-supervisor-error.log -priority=40 diff --git a/benchmark-service/deploy/qwen-benchmark-dispatch b/benchmark-service/deploy/qwen-benchmark-dispatch index 979fae6e7c8..40858d0aa75 100644 --- a/benchmark-service/deploy/qwen-benchmark-dispatch +++ b/benchmark-service/deploy/qwen-benchmark-dispatch @@ -2,59 +2,21 @@ set -euo pipefail readonly CONFIG=/srv/qwen-benchmark/config/benchmark.env -readonly SECRET_CONFIG=/srv/qwen-benchmark/config/benchmark.secret.env -readonly API_URL=http://127.0.0.1:8000/api/v1/runs - -repository='' -qwen_ref='' -qwen_commit='' -suite='' -github_run_id='' -github_run_attempt='' - -while (($#)); do - case "$1" in - --repository|--qwen-ref|--qwen-commit|--suite|--github-run-id|--github-run-attempt) - key="${1#--}" - shift - (($#)) || { echo "missing value for --${key}" >&2; exit 2; } - case "$key" in - repository) repository="$1" ;; - qwen-ref) qwen_ref="$1" ;; - qwen-commit) qwen_commit="$1" ;; - suite) suite="$1" ;; - github-run-id) github_run_id="$1" ;; - github-run-attempt) github_run_attempt="$1" ;; - esac - ;; - *) echo "unknown argument: $1" >&2; exit 2 ;; - esac - shift -done - -[[ "$repository" == 'QwenLM/qwen-code' ]] || { echo 'repository is not allowed' >&2; exit 2; } -[[ "$qwen_ref" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo 'qwen-ref must be a release version' >&2; exit 2; } -[[ "$qwen_commit" =~ ^[0-9a-f]{40}$ ]] || { echo 'qwen-commit must be a 40-character SHA' >&2; exit 2; } -[[ "$suite" =~ ^swebench_verified_(gold_smoke|qwen_smoke|harbor_smoke)$ ]] || { echo 'suite is not allowed' >&2; exit 2; } -[[ "$github_run_id" =~ ^[0-9]+$ && "$github_run_attempt" =~ ^[0-9]+$ ]] || { echo 'invalid GitHub run metadata' >&2; exit 2; } set -a source "$CONFIG" -source "$SECRET_CONFIG" set +a -payload="$(jq -n \ - --arg repository "$repository" \ - --arg qwen_ref "$qwen_ref" \ - --arg qwen_commit "$qwen_commit" \ - --arg suite "$suite" \ - --arg github_run_id "$github_run_id" \ - --arg github_run_attempt "$github_run_attempt" \ - '{repository: $repository, qwen_ref: $qwen_ref, qwen_commit: $qwen_commit, suite: $suite, trigger: "workflow_dispatch", github_run_id: ($github_run_id | tonumber), github_run_attempt: ($github_run_attempt | tonumber)}')" +submit=( + /srv/qwen-benchmark/venv/bin/qwen-benchmark-submit + "$@" +) + +if [[ "$(id -u)" -eq 0 ]]; then + exec runuser -u ecs-user -- env \ + "BENCHMARK_DATABASE_PATH=${BENCHMARK_DATABASE_PATH:?}" \ + "BENCHMARK_ALLOWED_REPOSITORY=${BENCHMARK_ALLOWED_REPOSITORY:-QwenLM/qwen-code}" \ + "${submit[@]}" +fi -curl --fail-with-body --silent --show-error \ - -X POST "$API_URL" \ - -H "Authorization: Bearer ${BENCHMARK_SHARED_TOKEN:?BENCHMARK_SHARED_TOKEN is required}" \ - -H 'Content-Type: application/json' \ - -H "Idempotency-Key: ${repository}:${github_run_id}:${github_run_attempt}" \ - --data "$payload" +exec "${submit[@]}" diff --git a/benchmark-service/deploy/qwen-benchmark-release-poller.service b/benchmark-service/deploy/qwen-benchmark-release-poller.service deleted file mode 100644 index cc394e4e1e1..00000000000 --- a/benchmark-service/deploy/qwen-benchmark-release-poller.service +++ /dev/null @@ -1,19 +0,0 @@ -[Unit] -Description=Queue the latest Qwen Code stable release benchmark -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -User=ecs-user -Group=ecs-user -WorkingDirectory=/srv/qwen-benchmark -EnvironmentFile=/srv/qwen-benchmark/config/benchmark.env -EnvironmentFile=/srv/qwen-benchmark/config/benchmark.secret.env -ExecStart=/srv/qwen-benchmark/venv/bin/qwen-benchmark-release-poller --once -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/srv/qwen-benchmark - diff --git a/benchmark-service/deploy/qwen-benchmark-release-poller.timer b/benchmark-service/deploy/qwen-benchmark-release-poller.timer deleted file mode 100644 index 6adebe4bc01..00000000000 --- a/benchmark-service/deploy/qwen-benchmark-release-poller.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Poll GitHub Releases every five minutes - -[Timer] -OnBootSec=2min -OnUnitActiveSec=5min -Persistent=true -Unit=qwen-benchmark-release-poller.service - -[Install] -WantedBy=timers.target diff --git a/benchmark-service/deploy/supervisord.conf b/benchmark-service/deploy/supervisord.conf deleted file mode 100644 index 1142a685fc1..00000000000 --- a/benchmark-service/deploy/supervisord.conf +++ /dev/null @@ -1,30 +0,0 @@ -[supervisord] -nodaemon=true -logfile=/tmp/qwen-benchmark/logs/supervisord.log -pidfile=/tmp/qwen-benchmark/supervisord.pid - -[program:dockerd] -command=/usr/bin/dockerd --host=unix:///var/run/docker.sock --data-root=/tmp/qwen-benchmark/docker --storage-driver=overlay2 -autorestart=true -startsecs=5 -stdout_logfile=/tmp/qwen-benchmark/logs/dockerd-supervisor.log -stderr_logfile=/tmp/qwen-benchmark/logs/dockerd-supervisor-error.log -priority=10 - -[program:benchmark-api] -command=/bin/bash -lc 'set -a; source /mnt/workspace/qwen-benchmark/config/benchmark.env; exec /mnt/workspace/qwen-benchmark/venv/bin/qwen-benchmark-api' -directory=/mnt/workspace/qwen-benchmark -autorestart=true -startsecs=3 -stdout_logfile=/tmp/qwen-benchmark/logs/api.log -stderr_logfile=/tmp/qwen-benchmark/logs/api-error.log -priority=20 - -[program:benchmark-worker] -command=/bin/bash -lc 'set -a; source /mnt/workspace/qwen-benchmark/config/benchmark.env; exec /mnt/workspace/qwen-benchmark/venv/bin/qwen-benchmark-worker' -directory=/mnt/workspace/qwen-benchmark -autorestart=true -startsecs=3 -stdout_logfile=/tmp/qwen-benchmark/logs/worker.log -stderr_logfile=/tmp/qwen-benchmark/logs/worker-error.log -priority=30 diff --git a/benchmark-service/deploy/systemd/qwen-benchmark-api.service b/benchmark-service/deploy/systemd/qwen-benchmark-api.service deleted file mode 100644 index e6301af5108..00000000000 --- a/benchmark-service/deploy/systemd/qwen-benchmark-api.service +++ /dev/null @@ -1,24 +0,0 @@ -[Unit] -Description=Qwen Code benchmark API -After=network-online.target docker.service -Wants=network-online.target -Requires=docker.service - -[Service] -Type=simple -User=ecs-user -Group=ecs-user -WorkingDirectory=/srv/qwen-benchmark -EnvironmentFile=/srv/qwen-benchmark/config/benchmark.env -EnvironmentFile=/srv/qwen-benchmark/config/benchmark.secret.env -ExecStart=/srv/qwen-benchmark/venv/bin/qwen-benchmark-api -Restart=on-failure -RestartSec=5 -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true -ReadWritePaths=/srv/qwen-benchmark - -[Install] -WantedBy=multi-user.target diff --git a/benchmark-service/deploy/systemd/qwen-benchmark-worker.service b/benchmark-service/deploy/systemd/qwen-benchmark-worker.service index e80d6f46949..25e855b94a6 100644 --- a/benchmark-service/deploy/systemd/qwen-benchmark-worker.service +++ b/benchmark-service/deploy/systemd/qwen-benchmark-worker.service @@ -1,6 +1,6 @@ [Unit] Description=Qwen Code benchmark worker -After=network-online.target docker.service qwen-benchmark-api.service +After=network-online.target docker.service Wants=network-online.target Requires=docker.service diff --git a/benchmark-service/pyproject.toml b/benchmark-service/pyproject.toml index 895419b666e..5a5bb0c04b1 100644 --- a/benchmark-service/pyproject.toml +++ b/benchmark-service/pyproject.toml @@ -8,19 +8,16 @@ version = "0.1.0" description = "Single-node Qwen Code SWE-bench Verified benchmark service" requires-python = ">=3.12" dependencies = [ - "fastapi>=0.115,<1", "httpx>=0.27,<1", - "PyJWT[crypto]>=2.9,<3", - "uvicorn>=0.34,<1", + "pydantic>=2.7,<3", ] [project.optional-dependencies] test = ["pytest>=8,<9"] [project.scripts] -qwen-benchmark-api = "qwen_benchmark.api:main" +qwen-benchmark-submit = "qwen_benchmark.submit:main" qwen-benchmark-worker = "qwen_benchmark.worker:main" -qwen-benchmark-release-poller = "qwen_benchmark.release_poller:main" [tool.setuptools.package-data] qwen_benchmark = ["suites.json"] diff --git a/benchmark-service/qwen_benchmark/api.py b/benchmark-service/qwen_benchmark/api.py deleted file mode 100644 index 84e09408159..00000000000 --- a/benchmark-service/qwen_benchmark/api.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import os - -import uvicorn -from fastapi import Depends, FastAPI, Header, HTTPException, status - -from .auth import authenticate -from .config import Settings, load_suites -from .models import RunDetail, RunRequest, RunResponse -from .store import Store - - -def create_app(settings: Settings | None = None) -> FastAPI: - settings = settings or Settings.from_env() - settings.prepare_directories() - store = Store(settings.database_path) - store.initialize() - suites = load_suites() - - app = FastAPI(title="Qwen Code Benchmark Service", version="0.1.0") - app.state.settings = settings - app.state.store = store - app.state.suites = suites - - @app.get("/healthz") - async def health() -> dict[str, str]: - return {"status": "ok"} - - @app.get("/readyz") - async def ready() -> dict[str, str]: - with store.connect() as connection: - connection.execute("SELECT 1").fetchone() - return {"status": "ready"} - - @app.post( - "/api/v1/runs", - response_model=RunResponse, - status_code=status.HTTP_202_ACCEPTED, - ) - async def create_run( - request: RunRequest, - idempotency_key: str = Header(alias="Idempotency-Key"), - claims: dict = Depends(authenticate), - ) -> RunResponse: - if not 1 <= len(idempotency_key) <= 255: - raise HTTPException(status_code=422, detail="invalid idempotency key") - if claims.get("repository") != request.repository: - raise HTTPException(status_code=403, detail="repository mismatch") - suite = suites.get(request.suite) - if not suite: - raise HTTPException(status_code=422, detail="suite not allowed") - row, deduplicated = store.create_run(request, suite, idempotency_key) - return RunResponse( - run_id=row["run_id"], - status=row["status"], - status_url=f"/api/v1/runs/{row['run_id']}", - deduplicated=deduplicated, - ) - - @app.get( - "/api/v1/runs/{run_id}", - response_model=RunDetail, - dependencies=[Depends(authenticate)], - ) - async def get_run(run_id: str) -> RunDetail: - row = store.get_run(run_id) - if not row: - raise HTTPException(status_code=404, detail="run not found") - return RunDetail(**row) - - @app.post( - "/api/v1/runs/{run_id}/cancel", - dependencies=[Depends(authenticate)], - ) - async def cancel_run(run_id: str) -> dict[str, str]: - if not store.get_run(run_id): - raise HTTPException(status_code=404, detail="run not found") - if not store.cancel(run_id): - raise HTTPException(status_code=409, detail="run cannot be canceled") - return {"run_id": run_id, "status": "CANCELED"} - - @app.post( - "/api/v1/runs/{run_id}/retry", - dependencies=[Depends(authenticate)], - ) - async def retry_run(run_id: str) -> dict[str, str]: - if not store.get_run(run_id): - raise HTTPException(status_code=404, detail="run not found") - if not store.retry(run_id): - raise HTTPException(status_code=409, detail="run cannot be retried") - return {"run_id": run_id, "status": "QUEUED"} - - return app - - -def main() -> None: - uvicorn.run( - create_app(), - host=os.environ.get("BENCHMARK_HOST", "127.0.0.1"), - port=int(os.environ.get("BENCHMARK_PORT", "8000")), - proxy_headers=True, - ) diff --git a/benchmark-service/qwen_benchmark/auth.py b/benchmark-service/qwen_benchmark/auth.py deleted file mode 100644 index 1b77df9e63b..00000000000 --- a/benchmark-service/qwen_benchmark/auth.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import hmac -from typing import Any - -import jwt -from fastapi import Header, HTTPException, Request - -from .config import Settings - - -GITHUB_ISSUER = "https://token.actions.githubusercontent.com" -GITHUB_JWKS_URL = f"{GITHUB_ISSUER}/.well-known/jwks" -_jwks_client = jwt.PyJWKClient(GITHUB_JWKS_URL) - - -async def authenticate( - request: Request, - authorization: str | None = Header(default=None), -) -> dict[str, Any]: - settings: Settings = request.app.state.settings - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException(status_code=401, detail="missing bearer token") - token = authorization.removeprefix("Bearer ") - - if settings.auth_mode == "token": - if not settings.shared_token: - raise HTTPException(status_code=503, detail="shared token not configured") - if not hmac.compare_digest(token, settings.shared_token): - raise HTTPException(status_code=401, detail="invalid bearer token") - return {"repository": settings.allowed_repository, "auth": "token"} - - try: - signing_key = _jwks_client.get_signing_key_from_jwt(token) - claims = jwt.decode( - token, - signing_key.key, - algorithms=["RS256"], - audience=settings.oidc_audience, - issuer=GITHUB_ISSUER, - options={"require": ["exp", "iat", "iss", "aud", "sub"]}, - ) - except jwt.PyJWTError as error: - raise HTTPException(status_code=401, detail="invalid OIDC token") from error - - if claims.get("repository") != settings.allowed_repository: - raise HTTPException(status_code=403, detail="repository not allowed") - if ( - settings.allowed_repository_id - and claims.get("repository_id") != settings.allowed_repository_id - ): - raise HTTPException(status_code=403, detail="repository id not allowed") - if ( - settings.allowed_workflow - and claims.get("workflow_ref") != settings.allowed_workflow - ): - raise HTTPException(status_code=403, detail="workflow not allowed") - if claims.get("event_name") not in {"release", "workflow_dispatch"}: - raise HTTPException(status_code=403, detail="event not allowed") - return claims diff --git a/benchmark-service/qwen_benchmark/config.py b/benchmark-service/qwen_benchmark/config.py index 81d3d862209..d859b8f4948 100644 --- a/benchmark-service/qwen_benchmark/config.py +++ b/benchmark-service/qwen_benchmark/config.py @@ -28,15 +28,9 @@ class Settings: artifact_root: Path qwen_repo: Path swebench_python: Path - auth_mode: Literal["token", "oidc"] - shared_token: str | None - oidc_audience: str allowed_repository: str - allowed_repository_id: str | None - allowed_workflow: str | None poll_seconds: float github_token: str | None - release_poll_suite: str = "swebench_verified_harbor_smoke" harbor_binary: Path = Path("/srv/qwen-benchmark/venv/bin/harbor") harbor_jobs_root: Path = Path("/srv/qwen-benchmark/harbor/jobs") benchmark_model: str | None = None @@ -65,19 +59,11 @@ def from_env(cls) -> "Settings": swebench_python=Path( os.environ.get("BENCHMARK_SWEBENCH_PYTHON", base / "venv/bin/python") ), - auth_mode=os.environ.get("BENCHMARK_AUTH_MODE", "oidc"), # type: ignore[arg-type] - shared_token=os.environ.get("BENCHMARK_SHARED_TOKEN"), - oidc_audience=os.environ.get("BENCHMARK_OIDC_AUDIENCE", "qwen-benchmark"), allowed_repository=os.environ.get( "BENCHMARK_ALLOWED_REPOSITORY", "QwenLM/qwen-code" ), - allowed_repository_id=os.environ.get("BENCHMARK_ALLOWED_REPOSITORY_ID"), - allowed_workflow=os.environ.get("BENCHMARK_ALLOWED_WORKFLOW"), poll_seconds=float(os.environ.get("BENCHMARK_POLL_SECONDS", "5")), github_token=os.environ.get("BENCHMARK_GITHUB_TOKEN"), - release_poll_suite=os.environ.get( - "BENCHMARK_RELEASE_POLL_SUITE", "swebench_verified_harbor_smoke" - ), harbor_binary=Path( os.environ.get( "BENCHMARK_HARBOR_BINARY", "/srv/qwen-benchmark/venv/bin/harbor" diff --git a/benchmark-service/qwen_benchmark/models.py b/benchmark-service/qwen_benchmark/models.py index 88a10ad6c86..afa74ffbc0d 100644 --- a/benchmark-service/qwen_benchmark/models.py +++ b/benchmark-service/qwen_benchmark/models.py @@ -27,30 +27,3 @@ def reject_revision_syntax(cls, value: str) -> str: if ".." in value or "@{" in value: raise ValueError("qwen_ref contains unsupported revision syntax") return value - - -class RunResponse(BaseModel): - run_id: str - status: str - status_url: str - deduplicated: bool = False - - -class RunDetail(BaseModel): - run_id: str - repository: str - qwen_ref: str - qwen_commit: str | None - suite: str - dataset: str - dataset_revision: str - runner_mode: str - status: str - expected_instances: int - completed_instances: int - resolved_instances: int - error: str | None - created_at: str - started_at: str | None - finished_at: str | None - heartbeat_at: str | None diff --git a/benchmark-service/qwen_benchmark/publisher.py b/benchmark-service/qwen_benchmark/publisher.py index 10e754cd2af..ed12b3735b4 100644 --- a/benchmark-service/qwen_benchmark/publisher.py +++ b/benchmark-service/qwen_benchmark/publisher.py @@ -8,42 +8,22 @@ from .config import Settings -def publish_check( +def publish_release( settings: Settings, run: dict[str, Any], summary: dict[str, Any], ) -> str: - if not settings.github_token or not run.get("qwen_commit"): + if not settings.github_token: return "SKIPPED" headers = { "Accept": "application/vnd.github+json", "Authorization": f"Bearer {settings.github_token}", "X-GitHub-Api-Version": "2022-11-28", } - conclusion = "success" if run["status"] == "SUCCEEDED" else "failure" release_id = json.loads(run["request_json"]).get("release_id") - if release_id: - _update_release_body(settings, run, summary, headers, int(release_id)) - response = httpx.post( - f"https://api.github.com/repos/{run['repository']}/check-runs", - headers=headers, - json={ - "name": f"Qwen Code Benchmark / {run['suite']}", - "head_sha": run["qwen_commit"], - "status": "completed", - "conclusion": conclusion, - "output": { - "title": f"{summary['resolved_instances']} / {summary['expected_instances']} resolved", - "summary": ( - f"Run `{run['run_id']}` completed with " - f"{summary['resolved_instances']} resolved instances and " - f"{summary['error_instances']} infrastructure errors." - ), - }, - }, - timeout=30, - ) - response.raise_for_status() + if not release_id: + return "SKIPPED" + _update_release_body(settings, run, summary, headers, int(release_id)) return "PUBLISHED" @@ -60,22 +40,63 @@ def _update_release_body( response.raise_for_status() existing_body = response.json().get("body") or "" marker = "" - section = "\n".join( - [ - marker, - "## Qwen Code benchmark", - "", - f"- Suite: `{summary['suite']}`", - f"- Qwen Code version: `{summary['qwen_version']}`", - f"- Commit: `{summary['qwen_commit']}`", - f"- Result: **{summary['resolved_instances']} / {summary['expected_instances']} resolved**", - f"- Run ID: `{summary['run_id']}`", - "", - ] - ) + section = _release_section(marker, summary, run["status"] == "SUCCEEDED") if marker in existing_body: body = existing_body.split(marker, 1)[0].rstrip() + "\n\n" + section else: body = existing_body.rstrip() + "\n\n" + section patch = httpx.patch(url, headers=headers, json={"body": body}, timeout=30) patch.raise_for_status() + + +def _release_section(marker: str, summary: dict[str, Any], succeeded: bool) -> str: + version = summary["qwen_version"] or summary["qwen_ref"] + commit = summary["qwen_commit"] + commit_url = f"https://github.com/{summary['repository']}/commit/{commit}" + lines = [ + marker, + "## Qwen Code benchmark", + "", + "| Field | Result |", + "| --- | --- |", + f"| Status | **{'Completed' if succeeded else 'Failed — not scored'}** |", + f"| Dataset | `{summary['dataset']}` at `{summary['dataset_revision']}` |", + f"| Suite | `{summary['suite']}` |", + f"| Evaluation | {_evaluation_method(summary['runner_mode'])} |", + f"| Cases | {summary['completed_instances']} / {summary['expected_instances']} completed |", + ] + if succeeded: + lines.extend( + [ + "| Results | " + f"{summary['resolved_instances']} resolved · " + f"{summary['unresolved_instances']} unresolved · " + f"{summary['error_instances']} infrastructure errors |", + f"| Score | **{_score(summary):.2f}%** |", + ] + ) + else: + lines.append("| Score | Not published |") + lines.extend( + [ + f"| Qwen Code | `{version}` · [`{commit[:7]}`]({commit_url}) |", + f"| Run | `{summary['run_id']}` |", + "", + ] + ) + return "\n".join(lines) + + +def _score(summary: dict[str, Any]) -> float: + expected = int(summary["expected_instances"]) + if expected <= 0: + return 0.0 + return int(summary["resolved_instances"]) * 100.0 / expected + + +def _evaluation_method(runner_mode: str) -> str: + return { + "harbor": "Qwen Code agent (Harbor)", + "qwen": "Qwen Code agent (SWE-bench harness)", + "gold": "Gold patch harness validation", + }.get(runner_mode, runner_mode) diff --git a/benchmark-service/qwen_benchmark/release_poller.py b/benchmark-service/qwen_benchmark/release_poller.py deleted file mode 100644 index 64f1bcd005f..00000000000 --- a/benchmark-service/qwen_benchmark/release_poller.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Outbound-only GitHub Release trigger for a single-node benchmark POC.""" - -from __future__ import annotations - -import argparse -import logging -from typing import Any - -import httpx - -from .config import Settings, load_suites -from .models import RunRequest -from .store import Store - - -LOGGER = logging.getLogger(__name__) -GITHUB_API = "https://api.github.com" - - -class ReleasePoller: - def __init__( - self, - settings: Settings, - store: Store, - *, - client: httpx.Client | None = None, - ) -> None: - self.settings = settings - self.store = store - self.client = client or httpx.Client(timeout=30) - - def poll_once(self) -> dict[str, Any] | None: - """Queue the latest stable Release once, using its immutable release ID. - - Selecting only the newest stable release deliberately avoids backfilling a - repository's historical releases when this timer is first enabled. - """ - if not self.settings.github_token: - raise RuntimeError("BENCHMARK_GITHUB_TOKEN is required for release polling") - suites = load_suites() - suite = suites.get(self.settings.release_poll_suite) - if not suite: - raise RuntimeError( - f"release poll suite is not allowlisted: {self.settings.release_poll_suite}" - ) - headers = { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {self.settings.github_token}", - "X-GitHub-Api-Version": "2022-11-28", - } - releases = self.client.get( - f"{GITHUB_API}/repos/{self.settings.allowed_repository}/releases", - headers=headers, - params={"per_page": 20}, - ) - releases.raise_for_status() - stable = [ - release - for release in releases.json() - if not release.get("draft") and not release.get("prerelease") - ] - if not stable: - LOGGER.info("no published stable release found") - return None - release = max(stable, key=lambda item: item.get("published_at") or "") - tag = release["tag_name"] - commit = self.client.get( - f"{GITHUB_API}/repos/{self.settings.allowed_repository}/commits/{tag}", - headers=headers, - ) - commit.raise_for_status() - request = RunRequest( - repository=self.settings.allowed_repository, - qwen_ref=tag, - qwen_commit=commit.json()["sha"], - suite=self.settings.release_poll_suite, - trigger="release", - release_id=int(release["id"]), - ) - row, deduplicated = self.store.create_run( - request, - suite, - f"github-release:{release['id']}:{self.settings.release_poll_suite}", - ) - LOGGER.info( - "%s benchmark run %s for release %s", - "reused" if deduplicated else "queued", - row["run_id"], - tag, - ) - return {"run_id": row["run_id"], "tag": tag, "deduplicated": deduplicated} - - -def main() -> None: - parser = argparse.ArgumentParser(description="Queue latest Qwen Code stable release") - parser.add_argument("--once", action="store_true", help="required for systemd timer use") - args = parser.parse_args() - if not args.once: - parser.error("only --once is supported; schedule it with systemd") - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") - settings = Settings.from_env() - settings.prepare_directories() - store = Store(settings.database_path) - store.initialize() - ReleasePoller(settings, store).poll_once() - diff --git a/benchmark-service/qwen_benchmark/store.py b/benchmark-service/qwen_benchmark/store.py index ad1adb8e7cb..5bc86889c9f 100644 --- a/benchmark-service/qwen_benchmark/store.py +++ b/benchmark-service/qwen_benchmark/store.py @@ -5,6 +5,8 @@ import sqlite3 import time import uuid +from collections.abc import Iterator +from contextlib import contextmanager from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -27,12 +29,17 @@ class Store: def __init__(self, database_path: Path): self.database_path = database_path - def connect(self) -> sqlite3.Connection: + @contextmanager + def connect(self) -> Iterator[sqlite3.Connection]: connection = sqlite3.connect(self.database_path, timeout=30) - connection.row_factory = sqlite3.Row - connection.execute("PRAGMA busy_timeout=30000") - connection.execute("PRAGMA foreign_keys=ON") - return connection + try: + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout=30000") + connection.execute("PRAGMA foreign_keys=ON") + with connection: + yield connection + finally: + connection.close() def initialize(self) -> None: self.database_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/benchmark-service/qwen_benchmark/submit.py b/benchmark-service/qwen_benchmark/submit.py new file mode 100644 index 00000000000..446458a9490 --- /dev/null +++ b/benchmark-service/qwen_benchmark/submit.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import argparse +import json +from collections.abc import Sequence +from typing import Any + +from pydantic import ValidationError + +from .config import Settings, load_suites +from .models import RunRequest +from .store import Store + + +def submit_run( + settings: Settings, + request: RunRequest, + idempotency_key: str, +) -> dict[str, Any]: + if request.repository != settings.allowed_repository: + raise ValueError(f"repository is not allowed: {request.repository}") + if not 1 <= len(idempotency_key) <= 255: + raise ValueError("idempotency key must contain 1 to 255 characters") + + suites = load_suites() + suite = suites.get(request.suite) + if not suite: + raise ValueError(f"suite is not allowlisted: {request.suite}") + + store = Store(settings.database_path) + store.initialize() + row, deduplicated = store.create_run(request, suite, idempotency_key) + return { + "run_id": row["run_id"], + "status": row["status"], + "deduplicated": deduplicated, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Submit a Qwen Code benchmark directly to the local SQLite store." + ) + parser.add_argument("--repository", default="QwenLM/qwen-code") + parser.add_argument("--qwen-ref", required=True) + parser.add_argument("--qwen-commit") + parser.add_argument("--suite", required=True) + parser.add_argument( + "--trigger", + choices=("release", "workflow_dispatch", "manual"), + required=True, + ) + parser.add_argument("--release-id", type=int) + parser.add_argument("--github-run-id", type=int) + parser.add_argument("--github-run-attempt", type=int) + parser.add_argument("--idempotency-key") + return parser + + +def main(argv: Sequence[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + if args.idempotency_key: + idempotency_key = args.idempotency_key + elif args.github_run_id is not None and args.github_run_attempt is not None: + idempotency_key = ( + f"{args.repository}:{args.github_run_id}:{args.github_run_attempt}" + ) + else: + parser.error( + "provide --idempotency-key or both --github-run-id and " + "--github-run-attempt" + ) + + try: + request = RunRequest( + repository=args.repository, + qwen_ref=args.qwen_ref, + qwen_commit=args.qwen_commit, + suite=args.suite, + trigger=args.trigger, + release_id=args.release_id, + github_run_id=args.github_run_id, + github_run_attempt=args.github_run_attempt, + ) + result = submit_run(Settings.from_env(), request, idempotency_key) + except (ValidationError, ValueError) as error: + parser.error(str(error)) + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/benchmark-service/qwen_benchmark/worker.py b/benchmark-service/qwen_benchmark/worker.py index bd026240d18..9fbb05368bc 100644 --- a/benchmark-service/qwen_benchmark/worker.py +++ b/benchmark-service/qwen_benchmark/worker.py @@ -9,7 +9,7 @@ from .artifacts import Artifacts from .config import Settings, Suite, load_suites from .harbor_runner import HarborRunner, qwen_version_from_ref -from .publisher import publish_check +from .publisher import publish_release from .runner import AgentError, InfrastructureError, RunResult, SwebenchRunner from .store import Store @@ -120,13 +120,7 @@ def run_once(self) -> bool: ) self._write_status(artifacts, run_id) artifacts.write_checksums() - current = self.store.get_run(run_id) - if current: - try: - publish_check(self.settings, current, summary) - except Exception as error: - LOGGER.exception("result publishing failed for %s", run_id) - artifacts.write_json("publisher-error.json", {"error": str(error)}) + self._publish(artifacts, run_id, summary) except AgentError as error: LOGGER.exception("agent failed for %s", run_id) for instance in self.store.get_instances(run_id): @@ -137,6 +131,7 @@ def run_once(self) -> bool: self.store.transition(run_id, "FAILED", error=str(error)) artifacts.write_json("error.json", {"class": "agent", "error": str(error)}) self._write_status(artifacts, run_id) + self._publish(artifacts, run_id, self._failed_summary(run_id)) except InfrastructureError as error: LOGGER.exception("infrastructure failed for %s", run_id) status = self.store.requeue_or_fail(run_id, str(error)) @@ -153,6 +148,8 @@ def run_once(self) -> bool: "error.json", {"class": "infrastructure", "error": str(error)} ) self._write_status(artifacts, run_id) + if status == "FAILED": + self._publish(artifacts, run_id, self._failed_summary(run_id)) except Exception as error: LOGGER.exception("unexpected worker failure for %s", run_id) artifacts.write_json( @@ -161,6 +158,7 @@ def run_once(self) -> bool: try: self.store.transition(run_id, "FAILED", error=str(error)) self._write_status(artifacts, run_id) + self._publish(artifacts, run_id, self._failed_summary(run_id)) except Exception: LOGGER.exception( "could not persist failure state for %s; worker remains alive", @@ -209,6 +207,7 @@ def _summary(self, run_id: str, result: RunResult) -> dict[str, Any]: "suite": run["suite"], "dataset": run["dataset"], "dataset_revision": run["dataset_revision"], + "runner_mode": run["runner_mode"], "expected_instances": run["expected_instances"], "completed_instances": result.completed, "resolved_instances": result.resolved, @@ -219,6 +218,46 @@ def _summary(self, run_id: str, result: RunResult) -> dict[str, Any]: "error_ids": result.error_ids, } + def _failed_summary(self, run_id: str) -> dict[str, Any]: + run = self.store.get_run(run_id) + if not run: + raise RuntimeError(f"run disappeared: {run_id}") + return { + "run_id": run_id, + "repository": run["repository"], + "qwen_ref": run["qwen_ref"], + "qwen_commit": run["qwen_commit"], + "qwen_version": ( + qwen_version_from_ref(run["qwen_ref"]) + if run["runner_mode"] == "harbor" + else None + ), + "suite": run["suite"], + "dataset": run["dataset"], + "dataset_revision": run["dataset_revision"], + "runner_mode": run["runner_mode"], + "expected_instances": run["expected_instances"], + "completed_instances": run["completed_instances"], + "resolved_instances": run["resolved_instances"], + "unresolved_instances": 0, + "error_instances": 0, + "resolved_ids": [], + "unresolved_ids": [], + "error_ids": [], + } + + def _publish( + self, artifacts: Artifacts, run_id: str, summary: dict[str, Any] + ) -> None: + current = self.store.get_run(run_id) + if not current: + return + try: + publish_release(self.settings, current, summary) + except Exception as error: + LOGGER.exception("result publishing failed for %s", run_id) + artifacts.write_json("publisher-error.json", {"error": str(error)}) + def _write_status(self, artifacts: Artifacts, run_id: str) -> None: run = self.store.get_run(run_id) if run: diff --git a/benchmark-service/tests/test_api.py b/benchmark-service/tests/test_api.py deleted file mode 100644 index 2ff3deee67e..00000000000 --- a/benchmark-service/tests/test_api.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from fastapi.testclient import TestClient - -from qwen_benchmark.config import Settings - - -def settings(tmp_path: Path) -> Settings: - return Settings( - database_path=tmp_path / "benchmark.db", - work_root=tmp_path / "work", - artifact_root=tmp_path / "artifacts", - qwen_repo=tmp_path, - swebench_python=Path("/usr/bin/python3"), - auth_mode="token", - shared_token="test-token", - oidc_audience="qwen-benchmark", - allowed_repository="QwenLM/qwen-code", - allowed_repository_id=None, - allowed_workflow=None, - poll_seconds=0.01, - github_token=None, - harbor_jobs_root=tmp_path / "harbor-jobs", - ) - - -def payload() -> dict: - return { - "repository": "QwenLM/qwen-code", - "qwen_ref": "HEAD", - "suite": "swebench_verified_gold_smoke", - "trigger": "manual", - } - - -def test_auth_suite_validation_and_idempotency(tmp_path: Path) -> None: - from qwen_benchmark.api import create_app - - client = TestClient(create_app(settings(tmp_path))) - headers = { - "Authorization": "Bearer test-token", - "Idempotency-Key": "test-run-1", - } - - assert client.post("/api/v1/runs", json=payload()).status_code == 401 - - invalid = payload() - invalid["suite"] = "arbitrary-command" - response = client.post("/api/v1/runs", json=invalid, headers=headers) - assert response.status_code == 422 - - invalid_ref = payload() - invalid_ref["qwen_ref"] = "--upload-pack=malicious" - response = client.post("/api/v1/runs", json=invalid_ref, headers=headers) - assert response.status_code == 422 - - first = client.post("/api/v1/runs", json=payload(), headers=headers) - assert first.status_code == 202 - assert first.json()["deduplicated"] is False - - second = client.post("/api/v1/runs", json=payload(), headers=headers) - assert second.status_code == 202 - assert second.json()["run_id"] == first.json()["run_id"] - assert second.json()["deduplicated"] is True - - detail = client.get( - f"/api/v1/runs/{first.json()['run_id']}", - headers={"Authorization": "Bearer test-token"}, - ) - assert detail.status_code == 200 - assert detail.json()["dataset"] == "princeton-nlp/SWE-bench_Verified" - assert detail.json()["expected_instances"] == 1 diff --git a/benchmark-service/tests/test_harbor_runner.py b/benchmark-service/tests/test_harbor_runner.py index cd424e0419e..655fbb39742 100644 --- a/benchmark-service/tests/test_harbor_runner.py +++ b/benchmark-service/tests/test_harbor_runner.py @@ -17,12 +17,7 @@ def settings(tmp_path: Path) -> Settings: artifact_root=tmp_path / "artifacts", qwen_repo=tmp_path, swebench_python=Path("/usr/bin/python3"), - auth_mode="token", - shared_token="test-token", - oidc_audience="qwen-benchmark", allowed_repository="QwenLM/qwen-code", - allowed_repository_id=None, - allowed_workflow=None, poll_seconds=0.01, github_token=None, harbor_binary=tmp_path / "harbor", diff --git a/benchmark-service/tests/test_publisher.py b/benchmark-service/tests/test_publisher.py index 9526514b908..a523eaab7c2 100644 --- a/benchmark-service/tests/test_publisher.py +++ b/benchmark-service/tests/test_publisher.py @@ -6,7 +6,7 @@ import httpx from qwen_benchmark.config import Settings -from qwen_benchmark.publisher import publish_check +from qwen_benchmark.publisher import publish_release def test_publish_updates_the_triggering_release(monkeypatch, tmp_path: Path) -> None: @@ -16,12 +16,7 @@ def test_publish_updates_the_triggering_release(monkeypatch, tmp_path: Path) -> artifact_root=tmp_path / "artifacts", qwen_repo=tmp_path, swebench_python=Path("/usr/bin/python3"), - auth_mode="token", - shared_token=None, - oidc_audience="qwen-benchmark", allowed_repository="QwenLM/qwen-code", - allowed_repository_id=None, - allowed_workflow=None, poll_seconds=1, github_token="token", ) @@ -35,15 +30,10 @@ def response(method: str, url: str, **kwargs): json={"body": "Original notes"}, request=httpx.Request(method.upper(), url), ) - return httpx.Response( - 201 if method == "post" else 200, - json={}, - request=httpx.Request(method.upper(), url), - ) + return httpx.Response(200, json={}, request=httpx.Request(method.upper(), url)) monkeypatch.setattr("qwen_benchmark.publisher.httpx.get", lambda url, **kwargs: response("get", url, **kwargs)) monkeypatch.setattr("qwen_benchmark.publisher.httpx.patch", lambda url, **kwargs: response("patch", url, **kwargs)) - monkeypatch.setattr("qwen_benchmark.publisher.httpx.post", lambda url, **kwargs: response("post", url, **kwargs)) run = { "run_id": "qwen-bench-test", "repository": "QwenLM/qwen-code", @@ -57,13 +47,77 @@ def response(method: str, url: str, **kwargs): "suite": "swebench_verified_harbor_smoke", "qwen_version": "0.19.7", "qwen_commit": "a" * 40, + "repository": "QwenLM/qwen-code", + "dataset": "swe-bench/swe-bench-verified", + "dataset_revision": "2", + "runner_mode": "harbor", + "completed_instances": 1, "resolved_instances": 1, "expected_instances": 1, + "unresolved_instances": 0, "error_instances": 0, } - assert publish_check(settings, run, summary) == "PUBLISHED" + assert publish_release(settings, run, summary) == "PUBLISHED" assert calls[0] == ("get", "https://api.github.com/repos/QwenLM/qwen-code/releases/123", None) assert calls[1][0] == "patch" assert "Qwen Code benchmark" in calls[1][2]["body"] - assert calls[2][0] == "post" + assert "`swe-bench/swe-bench-verified` at `2`" in calls[1][2]["body"] + assert "Qwen Code agent (Harbor)" in calls[1][2]["body"] + assert "1 / 1 completed" in calls[1][2]["body"] + assert "**100.00%**" in calls[1][2]["body"] + assert len(calls) == 2 + + +def test_failed_run_is_published_without_a_score(monkeypatch, tmp_path: Path) -> None: + settings = Settings( + database_path=tmp_path / "state.db", + work_root=tmp_path / "work", + artifact_root=tmp_path / "artifacts", + qwen_repo=tmp_path, + swebench_python=Path("/usr/bin/python3"), + allowed_repository="QwenLM/qwen-code", + poll_seconds=1, + github_token="token", + ) + calls: list[tuple[str, dict | None]] = [] + + def response(method: str, url: str, **kwargs): + calls.append((method, kwargs.get("json"))) + return httpx.Response( + 200, + json={"body": "Original notes"} if method == "get" else {}, + request=httpx.Request(method.upper(), url), + ) + + monkeypatch.setattr("qwen_benchmark.publisher.httpx.get", lambda url, **kwargs: response("get", url, **kwargs)) + monkeypatch.setattr("qwen_benchmark.publisher.httpx.patch", lambda url, **kwargs: response("patch", url, **kwargs)) + run = { + "run_id": "qwen-bench-failed", + "repository": "QwenLM/qwen-code", + "qwen_commit": "a" * 40, + "suite": "swebench_verified_harbor_smoke", + "status": "FAILED", + "request_json": json.dumps({"release_id": 123}), + } + summary = { + "run_id": "qwen-bench-failed", + "suite": "swebench_verified_harbor_smoke", + "qwen_version": "0.19.7", + "qwen_commit": "a" * 40, + "repository": "QwenLM/qwen-code", + "dataset": "swe-bench/swe-bench-verified", + "dataset_revision": "2", + "runner_mode": "harbor", + "completed_instances": 0, + "resolved_instances": 0, + "expected_instances": 1, + "unresolved_instances": 0, + "error_instances": 1, + } + + assert publish_release(settings, run, summary) == "PUBLISHED" + assert "not scored" in calls[1][1]["body"] + assert "Score | Not published" in calls[1][1]["body"] + assert "%" not in calls[1][1]["body"] + assert len(calls) == 2 diff --git a/benchmark-service/tests/test_release_poller.py b/benchmark-service/tests/test_release_poller.py deleted file mode 100644 index ce87ea394df..00000000000 --- a/benchmark-service/tests/test_release_poller.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import httpx - -from qwen_benchmark.config import Settings -from qwen_benchmark.release_poller import ReleasePoller -from qwen_benchmark.store import Store - - -def settings(tmp_path: Path) -> Settings: - return Settings( - database_path=tmp_path / "state/benchmark.db", - work_root=tmp_path / "work", - artifact_root=tmp_path / "artifacts", - qwen_repo=tmp_path, - swebench_python=Path("/usr/bin/python3"), - auth_mode="token", - shared_token="test-token", - oidc_audience="qwen-benchmark", - allowed_repository="QwenLM/qwen-code", - allowed_repository_id=None, - allowed_workflow=None, - poll_seconds=1, - github_token="test-github-token", - release_poll_suite="swebench_verified_harbor_smoke", - harbor_binary=tmp_path / "venv/bin/harbor", - harbor_jobs_root=tmp_path / "harbor/jobs", - ) - - -def test_poller_queues_latest_stable_release_once(tmp_path: Path) -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["Authorization"] == "Bearer test-github-token" - if request.url.path.endswith("/releases"): - return httpx.Response( - 200, - json=[ - {"id": 3, "tag_name": "v9.9.9-preview.0", "prerelease": True, "draft": False, "published_at": "2026-07-21T10:00:00Z"}, - {"id": 2, "tag_name": "v1.2.0", "prerelease": False, "draft": False, "published_at": "2026-07-21T09:00:00Z"}, - {"id": 1, "tag_name": "v1.1.0", "prerelease": False, "draft": False, "published_at": "2026-07-20T09:00:00Z"}, - ], - ) - assert request.url.path.endswith("/commits/v1.2.0") - return httpx.Response(200, json={"sha": "a" * 40}) - - config = settings(tmp_path) - config.prepare_directories() - store = Store(config.database_path) - store.initialize() - client = httpx.Client(transport=httpx.MockTransport(handler)) - poller = ReleasePoller(config, store, client=client) - - first = poller.poll_once() - second = poller.poll_once() - - assert first == {"run_id": first["run_id"], "tag": "v1.2.0", "deduplicated": False} - assert second == {"run_id": first["run_id"], "tag": "v1.2.0", "deduplicated": True} - run = store.get_run(first["run_id"]) - assert run and run["qwen_commit"] == "a" * 40 diff --git a/benchmark-service/tests/test_submit.py b/benchmark-service/tests/test_submit.py new file mode 100644 index 00000000000..71ae4bd1c45 --- /dev/null +++ b/benchmark-service/tests/test_submit.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import qwen_benchmark.store as store_module +from qwen_benchmark.config import Settings +from qwen_benchmark.models import RunRequest +from qwen_benchmark.store import Store +from qwen_benchmark.submit import submit_run + + +def settings(tmp_path: Path) -> Settings: + return Settings( + database_path=tmp_path / "state/benchmark.db", + work_root=tmp_path / "work", + artifact_root=tmp_path / "artifacts", + qwen_repo=tmp_path, + swebench_python=Path("/usr/bin/python3"), + allowed_repository="QwenLM/qwen-code", + poll_seconds=0.01, + github_token=None, + ) + + +def test_submit_creates_an_idempotent_local_run(tmp_path: Path) -> None: + request = RunRequest( + qwen_ref="v0.20.0-nightly.20260722.b98306b7e", + qwen_commit="a" * 40, + suite="swebench_verified_harbor_smoke", + trigger="workflow_dispatch", + release_id=123, + github_run_id=456, + github_run_attempt=1, + ) + + first = submit_run(settings(tmp_path), request, "QwenLM/qwen-code:456:1") + second = submit_run(settings(tmp_path), request, "QwenLM/qwen-code:456:1") + + assert first["status"] == "QUEUED" + assert first["deduplicated"] is False + assert second == { + "run_id": first["run_id"], + "status": "QUEUED", + "deduplicated": True, + } + + +def test_submit_rejects_a_suite_outside_the_allowlist(tmp_path: Path) -> None: + request = RunRequest( + qwen_ref="v0.20.0", + qwen_commit="a" * 40, + suite="untrusted-suite", + trigger="manual", + ) + + with pytest.raises(ValueError, match="not allowlisted"): + submit_run(settings(tmp_path), request, "manual-test") + + +def test_store_closes_connections(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + connections = [] + real_connect = store_module.sqlite3.connect + + def tracking_connect(*args, **kwargs): + connection = real_connect(*args, **kwargs) + connections.append(connection) + return connection + + monkeypatch.setattr(store_module.sqlite3, "connect", tracking_connect) + store = Store(tmp_path / "benchmark.db") + store.initialize() + assert store.get_run("missing") is None + + assert connections + for connection in connections: + with pytest.raises(store_module.sqlite3.ProgrammingError, match="closed"): + connection.execute("SELECT 1") diff --git a/benchmark-service/tests/test_worker.py b/benchmark-service/tests/test_worker.py index 6961ed88c44..1fd0697ab59 100644 --- a/benchmark-service/tests/test_worker.py +++ b/benchmark-service/tests/test_worker.py @@ -7,7 +7,7 @@ from qwen_benchmark.config import Settings, load_suites from qwen_benchmark.models import RunRequest -from qwen_benchmark.runner import RunResult +from qwen_benchmark.runner import AgentError, RunResult from qwen_benchmark.store import Store from qwen_benchmark.worker import Worker @@ -58,6 +58,11 @@ def heartbeat(self, run_id: str) -> None: super().heartbeat(run_id) +class FailingRunner(FakeRunner): + def run(self, *args, **kwargs) -> RunResult: + raise AgentError("agent setup failed") + + def test_worker_completes_and_writes_artifacts(tmp_path: Path) -> None: settings = Settings( database_path=tmp_path / "state/benchmark.db", @@ -65,12 +70,7 @@ def test_worker_completes_and_writes_artifacts(tmp_path: Path) -> None: artifact_root=tmp_path / "artifacts", qwen_repo=tmp_path, swebench_python=Path("/usr/bin/python3"), - auth_mode="token", - shared_token="test-token", - oidc_audience="qwen-benchmark", allowed_repository="QwenLM/qwen-code", - allowed_repository_id=None, - allowed_workflow=None, poll_seconds=0.01, github_token=None, harbor_jobs_root=tmp_path / "harbor-jobs", @@ -106,6 +106,7 @@ def test_worker_completes_and_writes_artifacts(tmp_path: Path) -> None: artifact_root = settings.artifact_root / run["run_id"] summary = json.loads((artifact_root / "summary.json").read_text()) assert summary["dataset"] == "princeton-nlp/SWE-bench_Verified" + assert summary["runner_mode"] == "gold" checksum_file = artifact_root / "checksums.sha256" for line in checksum_file.read_text().splitlines(): expected, relative_path = line.split(" ", 1) @@ -122,12 +123,7 @@ def test_transient_heartbeat_failure_does_not_abort_runner(tmp_path: Path) -> No artifact_root=tmp_path / "artifacts", qwen_repo=tmp_path, swebench_python=Path("/usr/bin/python3"), - auth_mode="token", - shared_token="test-token", - oidc_audience="qwen-benchmark", allowed_repository="QwenLM/qwen-code", - allowed_repository_id=None, - allowed_workflow=None, poll_seconds=0.01, github_token=None, harbor_jobs_root=tmp_path / "harbor-jobs", @@ -156,6 +152,49 @@ def test_transient_heartbeat_failure_does_not_abort_runner(tmp_path: Path) -> No assert store.heartbeat_calls >= 1 +def test_worker_publishes_terminal_failure_without_score(tmp_path: Path, monkeypatch) -> None: + settings = Settings( + database_path=tmp_path / "state/benchmark.db", + work_root=tmp_path / "work", + artifact_root=tmp_path / "artifacts", + qwen_repo=tmp_path, + swebench_python=Path("/usr/bin/python3"), + allowed_repository="QwenLM/qwen-code", + poll_seconds=0.01, + github_token="token", + harbor_jobs_root=tmp_path / "harbor-jobs", + ) + settings.prepare_directories() + store = Store(settings.database_path) + store.initialize() + suites = load_suites() + request = RunRequest( + qwen_ref="HEAD", + suite="swebench_verified_gold_smoke", + trigger="manual", + ) + run, _ = store.create_run(request, suites[request.suite], "worker-failure") + published: list[tuple[dict, dict]] = [] + monkeypatch.setattr( + "qwen_benchmark.worker.publish_release", + lambda settings, current, summary: published.append((current, summary)), + ) + + worker = Worker( + settings, + store, + suites, + runner_factory=lambda heartbeat: FailingRunner(heartbeat), + ) + assert worker.run_once() is True + + final = store.get_run(run["run_id"]) + assert final is not None and final["status"] == "FAILED" + assert len(published) == 1 + assert published[0][0]["status"] == "FAILED" + assert published[0][1]["resolved_instances"] == 0 + + def test_recover_interrupted_run_requeues_with_attempt_remaining( tmp_path: Path, ) -> None: