diff --git a/.github/workflows/benchmark-dispatch.yml b/.github/workflows/benchmark-dispatch.yml new file mode 100644 index 0000000000..5415023855 --- /dev/null +++ b/.github/workflows/benchmark-dispatch.yml @@ -0,0 +1,87 @@ +name: 'Dispatch SWE-bench Verified Benchmark' + +on: + release: + types: [published] + workflow_dispatch: + inputs: + qwen_ref: + description: 'Qwen Code release tag to benchmark.' + required: true + type: 'string' + suite: + description: 'Allowlisted benchmark suite.' + required: true + type: 'choice' + default: 'swebench_verified_harbor_smoke' + options: + - 'swebench_verified_harbor_smoke' + - 'swebench_verified_gold_smoke' + - 'swebench_verified_qwen_smoke' + release_id: + description: 'Optional existing prerelease ID to update after a manual test.' + required: false + type: 'string' + +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: 'Resolve immutable release commit' + id: 'revision' + shell: 'bash' + 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 queue' + env: + QWEN_REF: '${{ github.event.release.tag_name || inputs.qwen_ref }}' + QWEN_COMMIT: '${{ steps.revision.outputs.commit }}' + SUITE: "${{ inputs.suite || 'swebench_verified_harbor_smoke' }}" + RELEASE_ID: '${{ github.event.release.id || inputs.release_id }}' + TRIGGER: '${{ github.event_name }}' + run: |- + set -euo pipefail + 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 bf3401e657..7b81d07092 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -7,9 +7,33 @@ on: schedule: - cron: '30 0 * * *' workflow_dispatch: + inputs: + benchmark_poc: + description: 'Run the ECS benchmark POC instead of stale triage.' + required: false + type: boolean + default: false + qwen_ref: + description: 'Published Qwen Code tag to benchmark.' + required: false + type: string + default: '' + suite: + description: 'Allowlisted benchmark suite.' + required: false + type: choice + default: 'swebench_verified_gold_smoke' + 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: + if: '${{ github.event_name != ''workflow_dispatch'' || inputs.benchmark_poc == false }}' runs-on: 'ubuntu-latest' permissions: issues: 'write' @@ -45,3 +69,65 @@ jobs: # Cap per-run API operations to stay well under GitHub's hourly rate limit # and give the current PR backlog (~150) enough headroom across a few runs. operations-per-run: 100 + + benchmark-poc: + if: '${{ github.event_name == ''workflow_dispatch'' && inputs.benchmark_poc == true && github.repository == ''QwenLM/qwen-code'' }}' + name: 'ECS benchmark POC' + runs-on: [self-hosted, Linux, X64, qwen-benchmark] + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: 'Resolve immutable release commit' + id: revision + shell: bash + 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 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 + 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/.gitignore b/benchmark-service/.gitignore new file mode 100644 index 0000000000..74c96fa1df --- /dev/null +++ b/benchmark-service/.gitignore @@ -0,0 +1,7 @@ +.artifacts/ +.pytest_cache/ +.state/ +.venv/ +.work/ +*.egg-info/ +__pycache__/ diff --git a/benchmark-service/README.md b/benchmark-service/README.md new file mode 100644 index 0000000000..1383f7e8ef --- /dev/null +++ b/benchmark-service/README.md @@ -0,0 +1,528 @@ +# Qwen Code Benchmark Worker + +Qwen Code 发版后 Benchmark 的单机 ECS 执行服务。当前 POC 已打通: + +```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 + +只有 suite manifest 中 `publish: true` 的任务可以回写。publisher 会确认 Release tag 与 `qwen_ref` 一致,并限制人工触发只能更新 prerelease。失败结果只显示 `Failed — not scored`,不发布数值分数。Release 中使用成对 marker,重跑只替换 benchmark 区块,不会覆盖后续发布说明。 + +## 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 回写 + 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 +python3.12 -m venv .venv +.venv/bin/pip install -e '.[test]' +``` + +运行测试: + +```bash +PYTHONPATH=. .venv/bin/pytest -q +``` + +当前代码基线预期: + +```text +16 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=/path/to/qwen-code +export BENCHMARK_SWEBENCH_PYTHON="$PWD/.venv/bin/python" +.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]' +``` + +复制配置: + +```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= +``` + +模型凭证建议使用独立 root-only EnvironmentFile,不要写入仓库或普通配置模板。 + +安装 worker 服务: + +```bash +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 +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 +``` + +## 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 结束 +``` + +nightly 验收使用 `workflow_dispatch`,显式传入现有 prerelease 的 tag 和 Release ID;不会创建或更新正式 Release。 + +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 + +2026-07-22 使用以下配置完成端到端验证: + +- 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 秒 +- 测试:16 passed + +该结果仅证明单 case POC 链路和执行器可用,不能代表完整 SWE-bench Verified 分数。 + +## 15. 当前限制与下一步 + +- 当前 worker 和 Harbor case 执行均为串行。 +- SQLite 适合单机 POC,不适合多节点高写并发。 +- publisher 当前为 best-effort,API 失败会记录 `publisher-error.json`,尚无持久化 outbox。 +- 多 case run 的系统失败阈值尚未确定。 +- 镜像依赖节点 Docker cache,正式环境应使用 ACR 复制或预拉取。 +- 完整 SWE-bench Verified 运行前需实测资源、并发、超时和预算。 + +生产化建议依次完成: + +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 new file mode 100644 index 0000000000..26eb8a78f0 --- /dev/null +++ b/benchmark-service/deploy/benchmark.ecs.env @@ -0,0 +1,22 @@ +BENCHMARK_ROOT=/srv/qwen-benchmark +BENCHMARK_DATABASE_PATH=/srv/qwen-benchmark/state/benchmark.db +BENCHMARK_WORK_ROOT=/srv/qwen-benchmark/workspaces +BENCHMARK_ARTIFACT_ROOT=/srv/qwen-benchmark/artifacts +BENCHMARK_QWEN_REPO=/srv/qwen-benchmark/src/qwen-code +BENCHMARK_SWEBENCH_PYTHON=/srv/qwen-benchmark/venv/bin/python +BENCHMARK_HARBOR_BINARY=/srv/qwen-benchmark/venv/bin/harbor +BENCHMARK_HARBOR_JOBS_ROOT=/srv/qwen-benchmark/harbor/jobs +BENCHMARK_NPM_REGISTRY=https://registry.npmmirror.com +BENCHMARK_NPM_WAIT_SECONDS=600 +BENCHMARK_POLL_SECONDS=5 +BENCHMARK_ALLOWED_REPOSITORY=QwenLM/qwen-code +XDG_CACHE_HOME=/srv/qwen-benchmark/cache +NPM_CONFIG_CACHE=/srv/qwen-benchmark/cache/npm +DOCKER_CONFIG=/srv/qwen-benchmark/cache/docker +HF_HOME=/srv/qwen-benchmark/cache/huggingface +HF_ENDPOINT=https://hf-mirror.com +NPM_CONFIG_REGISTRY=https://registry.npmmirror.com + +# Required for a real Harbor + Qwen Code run; keep secrets in benchmark.secret.env. +# OPENAI_MODEL=qwen3-coder-plus +# OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 diff --git a/benchmark-service/deploy/benchmark.env.example b/benchmark-service/deploy/benchmark.env.example new file mode 100644 index 0000000000..a7cfb03ec2 --- /dev/null +++ b/benchmark-service/deploy/benchmark.env.example @@ -0,0 +1,12 @@ +BENCHMARK_ROOT=/mnt/workspace/qwen-benchmark +BENCHMARK_DATABASE_PATH=/mnt/workspace/qwen-benchmark/state/benchmark.db +BENCHMARK_WORK_ROOT=/tmp/qwen-benchmark/workspaces +BENCHMARK_ARTIFACT_ROOT=/mnt/data/qwen-benchmark/runs +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 +# BENCHMARK_GITHUB_TOKEN= +# OPENAI_API_KEY= +# OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +# OPENAI_MODEL=qwen3-coder-plus diff --git a/benchmark-service/deploy/docker-daemon.ecs.json b/benchmark-service/deploy/docker-daemon.ecs.json new file mode 100644 index 0000000000..1750852bc0 --- /dev/null +++ b/benchmark-service/deploy/docker-daemon.ecs.json @@ -0,0 +1,11 @@ +{ + "registry-mirrors": [ + "https://docker.m.daocloud.io", + "https://docker.1ms.run" + ], + "log-driver": "json-file", + "log-opts": { + "max-size": "100m", + "max-file": "3" + } +} diff --git a/benchmark-service/deploy/qwen-benchmark-dispatch b/benchmark-service/deploy/qwen-benchmark-dispatch new file mode 100644 index 0000000000..40858d0aa7 --- /dev/null +++ b/benchmark-service/deploy/qwen-benchmark-dispatch @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly CONFIG=/srv/qwen-benchmark/config/benchmark.env + +set -a +source "$CONFIG" +set +a + +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 + +exec "${submit[@]}" diff --git a/benchmark-service/deploy/qwen-benchmark-dispatch.sudoers b/benchmark-service/deploy/qwen-benchmark-dispatch.sudoers new file mode 100644 index 0000000000..8ea2b03032 --- /dev/null +++ b/benchmark-service/deploy/qwen-benchmark-dispatch.sudoers @@ -0,0 +1 @@ +qwen-runner ALL=(root) NOPASSWD: /usr/local/sbin/qwen-benchmark-dispatch diff --git a/benchmark-service/deploy/systemd/qwen-benchmark-worker.service b/benchmark-service/deploy/systemd/qwen-benchmark-worker.service new file mode 100644 index 0000000000..25e855b94a --- /dev/null +++ b/benchmark-service/deploy/systemd/qwen-benchmark-worker.service @@ -0,0 +1,28 @@ +[Unit] +Description=Qwen Code benchmark worker +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=ecs-user +Group=ecs-user +SupplementaryGroups=docker +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-worker +Restart=on-failure +RestartSec=5 +TimeoutStopSec=90 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=tmpfs +CacheDirectory=qwen-benchmark/harbor +BindPaths=/var/cache/qwen-benchmark/harbor:/home/ecs-user/.cache/harbor +ReadWritePaths=/srv/qwen-benchmark /var/lib/docker /var/run/docker.sock + +[Install] +WantedBy=multi-user.target diff --git a/benchmark-service/pyproject.toml b/benchmark-service/pyproject.toml new file mode 100644 index 0000000000..5a5bb0c04b --- /dev/null +++ b/benchmark-service/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "qwen-code-benchmark-service" +version = "0.1.0" +description = "Single-node Qwen Code SWE-bench Verified benchmark service" +requires-python = ">=3.12" +dependencies = [ + "httpx>=0.27,<1", + "pydantic>=2.7,<3", +] + +[project.optional-dependencies] +test = ["pytest>=8,<9"] + +[project.scripts] +qwen-benchmark-submit = "qwen_benchmark.submit:main" +qwen-benchmark-worker = "qwen_benchmark.worker:main" + +[tool.setuptools.package-data] +qwen_benchmark = ["suites.json"] + +[tool.setuptools.packages.find] +include = ["qwen_benchmark"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/benchmark-service/qwen_benchmark/__init__.py b/benchmark-service/qwen_benchmark/__init__.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/benchmark-service/qwen_benchmark/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/benchmark-service/qwen_benchmark/artifacts.py b/benchmark-service/qwen_benchmark/artifacts.py new file mode 100644 index 0000000000..95e5071f66 --- /dev/null +++ b/benchmark-service/qwen_benchmark/artifacts.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +from pathlib import Path +from typing import Any + + +class Artifacts: + def __init__(self, root: Path, run_id: str): + self.path = root / run_id + self.path.mkdir(parents=True, exist_ok=True) + + def write_json(self, relative_path: str, value: Any) -> Path: + target = self.path / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(target.suffix + ".tmp") + temporary.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.replace(temporary, target) + return target + + def copy(self, source: Path, relative_path: str) -> Path: + target = self.path / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(target.suffix + ".tmp") + shutil.copy2(source, temporary) + os.replace(temporary, target) + return target + + def copy_tree(self, source: Path, relative_path: str) -> None: + if not source.exists(): + return + target = self.path / relative_path + target.mkdir(parents=True, exist_ok=True) + shutil.copytree(source, target, dirs_exist_ok=True) + + def write_checksums(self) -> Path: + lines: list[str] = [] + for path in sorted(self.path.rglob("*")): + if not path.is_file() or path.name == "checksums.sha256": + continue + digest = hashlib.sha256(path.read_bytes()).hexdigest() + lines.append(f"{digest} {path.relative_to(self.path)}") + target = self.path / "checksums.sha256" + target.write_text("\n".join(lines) + "\n", encoding="utf-8") + return target diff --git a/benchmark-service/qwen_benchmark/config.py b/benchmark-service/qwen_benchmark/config.py new file mode 100644 index 0000000000..d859b8f494 --- /dev/null +++ b/benchmark-service/qwen_benchmark/config.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, NotRequired, TypedDict + + +class Suite(TypedDict): + dataset: str + dataset_revision: str + instance_ids: list[str] + runner_mode: Literal["gold", "qwen", "harbor"] + grader_concurrency: int + instance_timeout_seconds: int + publish: bool + harbor_dataset: NotRequired[str] + harbor_dataset_version: NotRequired[str] + harbor_task_prefix: NotRequired[str] + harbor_max_turns: NotRequired[int] + + +@dataclass(frozen=True) +class Settings: + database_path: Path + work_root: Path + artifact_root: Path + qwen_repo: Path + swebench_python: Path + allowed_repository: str + poll_seconds: float + github_token: str | None + 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 + npm_registry: str = "https://registry.npmjs.org" + npm_wait_seconds: int = 600 + + @classmethod + def from_env(cls) -> "Settings": + base = Path(os.environ.get("BENCHMARK_ROOT", "/mnt/workspace/qwen-benchmark")) + return cls( + database_path=Path( + os.environ.get("BENCHMARK_DATABASE_PATH", base / "state/benchmark.db") + ), + work_root=Path( + os.environ.get("BENCHMARK_WORK_ROOT", "/tmp/qwen-benchmark/workspaces") + ), + artifact_root=Path( + os.environ.get( + "BENCHMARK_ARTIFACT_ROOT", + "/mnt/data/qwen-benchmark/runs", + ) + ), + qwen_repo=Path( + os.environ.get("BENCHMARK_QWEN_REPO", base / "src/qwen-code") + ), + swebench_python=Path( + os.environ.get("BENCHMARK_SWEBENCH_PYTHON", base / "venv/bin/python") + ), + allowed_repository=os.environ.get( + "BENCHMARK_ALLOWED_REPOSITORY", "QwenLM/qwen-code" + ), + poll_seconds=float(os.environ.get("BENCHMARK_POLL_SECONDS", "5")), + github_token=os.environ.get("BENCHMARK_GITHUB_TOKEN"), + harbor_binary=Path( + os.environ.get( + "BENCHMARK_HARBOR_BINARY", "/srv/qwen-benchmark/venv/bin/harbor" + ) + ), + harbor_jobs_root=Path( + os.environ.get("BENCHMARK_HARBOR_JOBS_ROOT", base / "harbor/jobs") + ), + benchmark_model=os.environ.get("OPENAI_MODEL"), + npm_registry=os.environ.get( + "BENCHMARK_NPM_REGISTRY", "https://registry.npmmirror.com" + ), + npm_wait_seconds=int(os.environ.get("BENCHMARK_NPM_WAIT_SECONDS", "600")), + ) + + def prepare_directories(self) -> None: + self.database_path.parent.mkdir(parents=True, exist_ok=True) + self.work_root.mkdir(parents=True, exist_ok=True) + self.artifact_root.mkdir(parents=True, exist_ok=True) + self.harbor_jobs_root.mkdir(parents=True, exist_ok=True) + + +def load_suites(path: Path | None = None) -> dict[str, Suite]: + if path is None: + path = Path(__file__).with_name("suites.json") + with path.open(encoding="utf-8") as handle: + return json.load(handle) diff --git a/benchmark-service/qwen_benchmark/harbor_runner.py b/benchmark-service/qwen_benchmark/harbor_runner.py new file mode 100644 index 0000000000..45aa3c4cdd --- /dev/null +++ b/benchmark-service/qwen_benchmark/harbor_runner.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import json +import logging +import os +import re +import signal +import subprocess +import time +from pathlib import Path +from typing import Callable + +from .artifacts import Artifacts +from .config import Settings, Suite +from .runner import AgentError, InfrastructureError, RunResult + + +VERSION_RE = re.compile(r"^v?(?P\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$") +LOGGER = logging.getLogger(__name__) + + +def qwen_version_from_ref(qwen_ref: str) -> str: + match = VERSION_RE.fullmatch(qwen_ref) + if not match: + raise AgentError( + "Harbor runs require a Qwen Code release tag such as v0.19.7" + ) + return match.group("version") + + +class HarborRunner: + def __init__(self, settings: Settings, heartbeat: Callable[[], None]): + self.settings = settings + self.heartbeat = heartbeat + + def resolve_qwen_commit(self, qwen_ref: str) -> str: + result = subprocess.run( + [ + "git", + "-C", + str(self.settings.qwen_repo), + "rev-parse", + f"{qwen_ref}^{{commit}}", + ], + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise InfrastructureError( + "release commit is not present locally; dispatch must include qwen_commit" + ) + return result.stdout.strip() + + def run( + self, + run_id: str, + qwen_commit: str, + suite: Suite, + artifacts: Artifacts, + on_grading: Callable[[], None] | None = None, + qwen_ref: str | None = None, + ) -> RunResult: + if not qwen_ref: + raise AgentError("qwen_ref is required for a Harbor run") + version = qwen_version_from_ref(qwen_ref) + npm_metadata = self._wait_for_npm_release(version) + artifacts.write_json("release/npm-metadata.json", npm_metadata) + + if not self.settings.benchmark_model: + raise AgentError("OPENAI_MODEL is not configured") + if not os.environ.get("OPENAI_API_KEY"): + raise AgentError("OPENAI_API_KEY is not configured") + if not os.environ.get("OPENAI_BASE_URL"): + raise AgentError("OPENAI_BASE_URL is not configured") + if not self.settings.harbor_binary.is_file(): + raise InfrastructureError( + f"Harbor binary not found: {self.settings.harbor_binary}" + ) + + run_jobs_base = self.settings.harbor_jobs_root / run_id + run_jobs_base.mkdir(parents=True, exist_ok=True) + attempt_number = 1 + while (run_jobs_base / f"attempt-{attempt_number:02d}").exists(): + attempt_number += 1 + run_jobs_root = run_jobs_base / f"attempt-{attempt_number:02d}" + run_jobs_root.mkdir(parents=True, exist_ok=True) + work_dir = self.settings.work_root / run_id + work_dir.mkdir(parents=True, exist_ok=True) + + harbor_dataset = suite.get( + "harbor_dataset", "swe-bench/swe-bench-verified" + ) + dataset_version = suite.get("harbor_dataset_version") + dataset_spec = ( + f"{harbor_dataset}@{dataset_version}" + if dataset_version + else harbor_dataset + ) + + result: RunResult | None = None + try: + for instance_id in suite["instance_ids"]: + harbor_task_name = f"{suite.get('harbor_task_prefix', '')}{instance_id}" + command = [ + str(self.settings.harbor_binary), + "run", + "--dataset", + dataset_spec, + "--include-task-name", + harbor_task_name, + "--agent", + "qwen-coder", + "--model", + self.settings.benchmark_model, + "--agent-kwarg", + f"version={version}", + "--agent-kwarg", + f"max_turns={suite.get('harbor_max_turns', 200)}", + "--env", + "docker", + "--n-concurrent", + "1", + "--max-retries", + "0", + "--job-name", + instance_id, + "--jobs-dir", + str(run_jobs_root), + "--yes", + ] + returncode = self._command( + command, + work_dir, + work_dir / f"harbor-{instance_id}.log", + suite["instance_timeout_seconds"], + ) + job_dir = run_jobs_root / instance_id + if returncode != 0 and not list(job_dir.glob("*/result.json")): + raise InfrastructureError( + f"Harbor failed before producing a trial result: {instance_id}" + ) + + if on_grading: + on_grading() + result = self._parse_results(run_jobs_root, suite, version, work_dir) + return result + finally: + # Preserve all evidence even when Harbor, the verifier, or state-store + # bookkeeping fails after a trial has already produced useful output. + try: + artifacts.copy_tree( + run_jobs_root, f"harbor/jobs/attempt-{attempt_number:02d}" + ) + for instance_id in suite["instance_ids"]: + log_path = work_dir / f"harbor-{instance_id}.log" + if log_path.exists(): + artifacts.copy(log_path, f"harbor/logs/{instance_id}.log") + if result is not None: + artifacts.copy( + result.report_path, "harbor/evaluation-report.json" + ) + except OSError: + LOGGER.exception("failed to collect Harbor artifacts for %s", run_id) + + def _wait_for_npm_release(self, version: str) -> dict: + deadline = time.monotonic() + self.settings.npm_wait_seconds + command = [ + "npm", + "view", + f"@qwen-code/qwen-code@{version}", + "version", + "dist.integrity", + "dist.tarball", + "--json", + "--registry", + self.settings.npm_registry, + ] + env = os.environ.copy() + env["NPM_CONFIG_REGISTRY"] = self.settings.npm_registry + env.setdefault("NPM_CONFIG_CACHE", "/srv/qwen-benchmark/cache/npm") + last_error = "npm release not found" + while True: + try: + result = subprocess.run( + command, + text=True, + capture_output=True, + timeout=60, + env=env, + ) + except subprocess.TimeoutExpired: + last_error = "npm view timed out after 60 seconds" + result = None + if result is not None and result.returncode == 0: + try: + metadata = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise InfrastructureError("npm returned invalid JSON") from error + if metadata.get("version") != version: + raise AgentError( + f"npm version mismatch: expected {version}, got {metadata.get('version')}" + ) + integrity = metadata.get("dist.integrity") or ( + metadata.get("dist") or {} + ).get("integrity") + if not integrity: + raise InfrastructureError("npm metadata is missing dist.integrity") + return metadata + if result is not None: + last_error = (result.stderr or result.stdout).strip()[-1000:] + if time.monotonic() >= deadline: + raise InfrastructureError( + f"Qwen Code npm release {version} is unavailable: {last_error}" + ) + LOGGER.warning( + "Qwen Code npm release %s is not visible yet; retrying: %s", + version, + last_error, + ) + self.heartbeat() + time.sleep(min(30, max(self.settings.npm_wait_seconds, 1))) + + def _parse_results( + self, + run_jobs_root: Path, + suite: Suite, + expected_version: str, + work_dir: Path, + ) -> RunResult: + expected = set(suite["instance_ids"]) + seen: set[str] = set() + resolved_ids: list[str] = [] + unresolved_ids: list[str] = [] + error_ids: list[str] = [] + details: list[dict] = [] + + for result_path in sorted(run_jobs_root.glob("*/*/result.json")): + result = json.loads(result_path.read_text(encoding="utf-8")) + instance_id = result.get("task_name") or result.get("task_id") + task_prefix = suite.get("harbor_task_prefix", "") + if task_prefix and isinstance(instance_id, str): + instance_id = instance_id.removeprefix(task_prefix) + if instance_id not in expected or instance_id in seen: + continue + seen.add(instance_id) + agent_info = result.get("agent_info") or {} + actual_version = str(agent_info.get("version") or "") + exception = result.get("exception_info") + rewards = (result.get("verifier_result") or {}).get("rewards") or {} + reward = rewards.get("reward") + + if actual_version != expected_version: + raise AgentError( + f"Qwen Code version mismatch for {instance_id}: " + f"expected {expected_version}, got {actual_version or 'unknown'}" + ) + if exception is not None or not isinstance(reward, (int, float)): + error_ids.append(instance_id) + elif float(reward) > 0: + resolved_ids.append(instance_id) + else: + unresolved_ids.append(instance_id) + details.append( + { + "instance_id": instance_id, + "qwen_version": actual_version, + "reward": reward, + "has_exception": exception is not None, + "result_path": str(result_path.relative_to(run_jobs_root)), + } + ) + + for instance_id in sorted(expected - seen): + error_ids.append(instance_id) + + completed = len(resolved_ids) + len(unresolved_ids) + report = { + "completed_instances": completed, + "resolved_instances": len(resolved_ids), + "resolved_ids": sorted(resolved_ids), + "unresolved_ids": sorted(unresolved_ids), + "error_ids": sorted(error_ids), + "qwen_version": expected_version, + "trials": details, + } + report_path = work_dir / "harbor-evaluation-report.json" + report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return RunResult( + completed=completed, + resolved=len(resolved_ids), + resolved_ids=sorted(resolved_ids), + unresolved_ids=sorted(unresolved_ids), + error_ids=sorted(error_ids), + report_path=report_path, + ) + + def _command( + self, command: list[str], cwd: Path, log_path: Path, timeout_seconds: int + ) -> int: + env = os.environ.copy() + env["NPM_CONFIG_REGISTRY"] = self.settings.npm_registry + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("w", encoding="utf-8") as log: + process = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + deadline = time.monotonic() + timeout_seconds + while process.poll() is None: + if time.monotonic() >= deadline: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + raise AgentError(f"Harbor trial timed out: {command[5]}") + self.heartbeat() + time.sleep(5) + return process.returncode diff --git a/benchmark-service/qwen_benchmark/models.py b/benchmark-service/qwen_benchmark/models.py new file mode 100644 index 0000000000..afa74ffbc0 --- /dev/null +++ b/benchmark-service/qwen_benchmark/models.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class RunRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + repository: Literal["QwenLM/qwen-code"] = "QwenLM/qwen-code" + qwen_ref: str = Field( + min_length=1, + max_length=160, + pattern=r"^[A-Za-z0-9][A-Za-z0-9._/@+-]*$", + ) + qwen_commit: str | None = Field(default=None, pattern=r"^[0-9a-fA-F]{40}$") + suite: str = Field(min_length=1, max_length=100) + trigger: Literal["release", "workflow_dispatch", "manual"] + release_id: int | None = None + github_run_id: int | None = None + github_run_attempt: int | None = None + + @field_validator("qwen_ref") + @classmethod + def reject_revision_syntax(cls, value: str) -> str: + if ".." in value or "@{" in value: + raise ValueError("qwen_ref contains unsupported revision syntax") + return value diff --git a/benchmark-service/qwen_benchmark/publisher.py b/benchmark-service/qwen_benchmark/publisher.py new file mode 100644 index 0000000000..e3fa43a7a2 --- /dev/null +++ b/benchmark-service/qwen_benchmark/publisher.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from typing import Any + +import httpx + +from .config import Settings + + +START_MARKER = "" +END_MARKER = "" +LEGACY_MARKER = "" + + +def publish_release( + settings: Settings, + run: dict[str, Any], + summary: dict[str, Any], +) -> str: + 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", + } + release_id = json.loads(run["request_json"]).get("release_id") + if not release_id: + return "SKIPPED" + _update_release_body(settings, run, summary, headers, int(release_id)) + return "PUBLISHED" + + +def _update_release_body( + settings: Settings, + run: dict[str, Any], + summary: dict[str, Any], + headers: dict[str, str], + release_id: int, +) -> None: + """Append a replaceable, public-safe benchmark summary to the Release.""" + url = f"https://api.github.com/repos/{run['repository']}/releases/{release_id}" + response = httpx.get(url, headers=headers, timeout=30) + response.raise_for_status() + release = response.json() + if release.get("tag_name") != run["qwen_ref"]: + raise ValueError("release tag does not match the benchmarked Qwen Code ref") + request = json.loads(run["request_json"]) + if request.get("trigger") == "workflow_dispatch" and not release.get( + "prerelease" + ): + raise ValueError("manual benchmark publication requires a prerelease") + + existing_body = release.get("body") or "" + section = ( + _release_section(START_MARKER, summary, run["status"] == "SUCCEEDED") + + END_MARKER + ) + if START_MARKER in existing_body and END_MARKER in existing_body: + prefix, remainder = existing_body.split(START_MARKER, 1) + _, suffix = remainder.split(END_MARKER, 1) + body = prefix.rstrip() + "\n\n" + section + suffix + elif LEGACY_MARKER in existing_body: + body = ( + existing_body.split(LEGACY_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/runner.py b/benchmark-service/qwen_benchmark/runner.py new file mode 100644 index 0000000000..5056540187 --- /dev/null +++ b/benchmark-service/qwen_benchmark/runner.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import json +import os +import signal +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .artifacts import Artifacts +from .config import Settings, Suite + + +class InfrastructureError(RuntimeError): + pass + + +class AgentError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RunResult: + completed: int + resolved: int + resolved_ids: list[str] + unresolved_ids: list[str] + error_ids: list[str] + report_path: Path + + +class SwebenchRunner: + def __init__( + self, + settings: Settings, + heartbeat: Callable[[], None], + ): + self.settings = settings + self.heartbeat = heartbeat + + def resolve_qwen_commit(self, qwen_ref: str) -> str: + result = subprocess.run( + [ + "git", + "-C", + str(self.settings.qwen_repo), + "rev-parse", + f"{qwen_ref}^{{commit}}", + ], + text=True, + capture_output=True, + ) + if result.returncode != 0: + fetch = subprocess.run( + [ + "git", + "-C", + str(self.settings.qwen_repo), + "fetch", + "--tags", + "origin", + qwen_ref, + ], + text=True, + capture_output=True, + ) + if fetch.returncode != 0: + raise InfrastructureError(fetch.stderr.strip() or "git fetch failed") + result = subprocess.run( + [ + "git", + "-C", + str(self.settings.qwen_repo), + "rev-parse", + f"{qwen_ref}^{{commit}}", + ], + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise AgentError(result.stderr.strip() or "qwen ref not found") + return result.stdout.strip() + + def run( + self, + run_id: str, + qwen_commit: str, + suite: Suite, + artifacts: Artifacts, + on_grading: Callable[[], None] | None = None, + qwen_ref: str | None = None, + ) -> RunResult: + work_dir = self.settings.work_root / run_id + work_dir.mkdir(parents=True, exist_ok=True) + predictions: Path | str = "gold" + if suite["runner_mode"] == "qwen": + predictions = self._run_qwen( + run_id, qwen_commit, suite, work_dir, artifacts + ) + if on_grading: + on_grading() + return self._grade(run_id, suite, work_dir, predictions, artifacts) + + def _run_qwen( + self, + run_id: str, + qwen_commit: str, + suite: Suite, + work_dir: Path, + artifacts: Artifacts, + ) -> Path: + if not os.environ.get("OPENAI_API_KEY"): + raise AgentError("OPENAI_API_KEY is not configured") + + try: + from datasets import load_dataset + except ImportError as error: + raise InfrastructureError("datasets package is not installed") from error + + qwen_source = work_dir / "qwen-code" + self._command( + [ + "git", + "-C", + str(self.settings.qwen_repo), + "worktree", + "add", + "--detach", + str(qwen_source), + qwen_commit, + ], + work_dir, + work_dir / "prepare-qwen.log", + 300, + ) + self._command( + ["npm", "ci", "--no-audit", "--progress=false"], + qwen_source, + work_dir / "npm-ci.log", + 1200, + ) + self._command( + ["npm", "run", "build"], + qwen_source, + work_dir / "qwen-build.log", + 1200, + ) + + dataset = load_dataset( + suite["dataset"], + split="test", + revision=suite["dataset_revision"], + ) + by_id = {item["instance_id"]: item for item in dataset} + predictions: list[dict[str, str]] = [] + + for instance_id in suite["instance_ids"]: + instance = by_id.get(instance_id) + if not instance: + raise InfrastructureError(f"dataset instance missing: {instance_id}") + repository = work_dir / "repositories" / instance_id + repository.parent.mkdir(parents=True, exist_ok=True) + self._command( + [ + "git", + "clone", + "--quiet", + f"https://github.com/{instance['repo']}.git", + str(repository), + ], + work_dir, + work_dir / f"{instance_id}-clone.log", + 900, + ) + self._command( + ["git", "checkout", "--quiet", instance["base_commit"]], + repository, + work_dir / f"{instance_id}-checkout.log", + 120, + ) + prompt = ( + "Solve the following GitHub issue in this repository. " + "Modify the working tree, run focused tests when practical, " + "and do not commit the changes.\n\n" + instance["problem_statement"] + ) + self._command( + [ + "node", + str(qwen_source / "scripts/cli-entry.js"), + "-y", + "--prompt", + prompt, + ], + repository, + work_dir / f"{instance_id}-agent.log", + suite["instance_timeout_seconds"], + AgentError, + ) + subprocess.run(["git", "add", "-N", "."], cwd=repository, check=False) + patch = subprocess.run( + ["git", "diff", "--binary"], + cwd=repository, + text=True, + capture_output=True, + check=True, + ).stdout + patch_path = work_dir / f"{instance_id}.patch" + patch_path.write_text(patch, encoding="utf-8") + artifacts.copy(patch_path, f"instances/{instance_id}/patch.diff") + artifacts.copy( + work_dir / f"{instance_id}-agent.log", + f"instances/{instance_id}/agent.log", + ) + predictions.append( + { + "instance_id": instance_id, + "model_name_or_path": f"qwen-code@{qwen_commit}", + "model_patch": patch, + } + ) + + predictions_path = work_dir / "predictions.jsonl" + predictions_path.write_text( + "".join(json.dumps(item) + "\n" for item in predictions), + encoding="utf-8", + ) + artifacts.copy(predictions_path, "predictions.jsonl") + return predictions_path + + def _grade( + self, + run_id: str, + suite: Suite, + work_dir: Path, + predictions: Path | str, + artifacts: Artifacts, + ) -> RunResult: + command = [ + str(self.settings.swebench_python), + "-m", + "swebench.harness.run_evaluation", + "--dataset_name", + suite["dataset"], + "--predictions_path", + str(predictions), + "--max_workers", + str(suite["grader_concurrency"]), + "--run_id", + run_id, + "--cache_level", + "env", + "--instance_ids", + *suite["instance_ids"], + ] + self._command( + command, + work_dir, + work_dir / "grader.log", + max(suite["instance_timeout_seconds"] * len(suite["instance_ids"]), 600), + ) + reports = sorted(work_dir.glob(f"*.{run_id}.json")) + if len(reports) != 1: + raise InfrastructureError( + f"expected one grader report for {run_id}, found {len(reports)}" + ) + report_path = reports[0] + report = json.loads(report_path.read_text(encoding="utf-8")) + artifacts.copy(report_path, "grader/evaluation-report.json") + artifacts.copy(work_dir / "grader.log", "grader/grader.log") + artifacts.copy_tree( + work_dir / "logs/run_evaluation" / run_id, + "grader/run_evaluation", + ) + return RunResult( + completed=int(report["completed_instances"]), + resolved=int(report["resolved_instances"]), + resolved_ids=list(report["resolved_ids"]), + unresolved_ids=list(report["unresolved_ids"]), + error_ids=list(report["error_ids"]), + report_path=report_path, + ) + + def _command( + self, + command: list[str], + cwd: Path, + log_path: Path, + timeout_seconds: int, + failure_type: type[RuntimeError] = InfrastructureError, + ) -> None: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("w", encoding="utf-8") as log: + process = subprocess.Popen( + command, + cwd=cwd, + env=os.environ.copy(), + stdout=log, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + deadline = time.monotonic() + timeout_seconds + while process.poll() is None: + if time.monotonic() >= deadline: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=15) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + raise failure_type(f"command timed out: {command[0]}") + self.heartbeat() + time.sleep(5) + if process.returncode != 0: + tail = log_path.read_text(encoding="utf-8", errors="replace")[-4000:] + raise failure_type( + f"command failed ({process.returncode}): {' '.join(command[:4])}\n{tail}" + ) diff --git a/benchmark-service/qwen_benchmark/store.py b/benchmark-service/qwen_benchmark/store.py new file mode 100644 index 0000000000..5bc86889c9 --- /dev/null +++ b/benchmark-service/qwen_benchmark/store.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import json +import logging +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 + +from .config import Suite +from .models import RunRequest + + +TERMINAL_RUN_STATES = {"SUCCEEDED", "FAILED", "CANCELED"} +ACTIVE_RUN_STATES = {"PREPARING", "RUNNING_AGENT", "GRADING", "UPLOADING"} +LOGGER = logging.getLogger(__name__) +SQLITE_RETRY_DELAYS = (0.1, 0.25, 0.5, 1.0) + + +def now() -> str: + return datetime.now(UTC).isoformat() + + +class Store: + def __init__(self, database_path: Path): + self.database_path = database_path + + @contextmanager + def connect(self) -> Iterator[sqlite3.Connection]: + connection = sqlite3.connect(self.database_path, timeout=30) + 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) + with self.connect() as connection: + # Journal mode is persistent database configuration. Reapplying it on + # every heartbeat creates an unnecessary failure point while Harbor is + # running and can require opening the WAL sidecar repeatedly. + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA synchronous=NORMAL") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + repository TEXT NOT NULL, + qwen_ref TEXT NOT NULL, + qwen_commit TEXT, + suite TEXT NOT NULL, + dataset TEXT NOT NULL, + dataset_revision TEXT NOT NULL, + runner_mode TEXT NOT NULL, + status TEXT NOT NULL, + request_json TEXT NOT NULL, + expected_instances INTEGER NOT NULL, + completed_instances INTEGER NOT NULL DEFAULT 0, + resolved_instances INTEGER NOT NULL DEFAULT 0, + attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 2, + error TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + heartbeat_at TEXT + ); + + CREATE TABLE IF NOT EXISTS instances ( + run_id TEXT NOT NULL, + instance_id TEXT NOT NULL, + status TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + error TEXT, + started_at TEXT, + finished_at TEXT, + PRIMARY KEY (run_id, instance_id), + FOREIGN KEY (run_id) REFERENCES runs(run_id) + ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + event_type TEXT NOT NULL, + detail_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES runs(run_id) + ); + """ + ) + + def create_run( + self, + request: RunRequest, + suite: Suite, + idempotency_key: str, + ) -> tuple[dict[str, Any], bool]: + created_at = now() + run_id = f"qwen-bench-{uuid.uuid4().hex[:16]}" + with self.connect() as connection: + connection.execute("BEGIN IMMEDIATE") + existing = connection.execute( + "SELECT * FROM runs WHERE idempotency_key = ?", + (idempotency_key,), + ).fetchone() + if existing: + return dict(existing), True + + connection.execute( + """ + INSERT INTO runs ( + run_id, idempotency_key, repository, qwen_ref, qwen_commit, suite, + dataset, dataset_revision, runner_mode, status, + request_json, expected_instances, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?) + """, + ( + run_id, + idempotency_key, + request.repository, + request.qwen_ref, + request.qwen_commit, + request.suite, + suite["dataset"], + suite["dataset_revision"], + suite["runner_mode"], + request.model_dump_json(), + len(suite["instance_ids"]), + created_at, + ), + ) + connection.executemany( + """ + INSERT INTO instances (run_id, instance_id, status) + VALUES (?, ?, 'PENDING') + """, + [(run_id, instance_id) for instance_id in suite["instance_ids"]], + ) + self._event(connection, run_id, "RUN_CREATED", request.model_dump()) + row = connection.execute( + "SELECT * FROM runs WHERE run_id = ?", (run_id,) + ).fetchone() + return dict(row), False + + def get_run(self, run_id: str) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + "SELECT * FROM runs WHERE run_id = ?", (run_id,) + ).fetchone() + return dict(row) if row else None + + def get_instances(self, run_id: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + "SELECT * FROM instances WHERE run_id = ? ORDER BY instance_id", + (run_id,), + ).fetchall() + return [dict(row) for row in rows] + + def claim_run(self) -> dict[str, Any] | None: + claimed_at = now() + with self.connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + """ + SELECT * FROM runs + WHERE status = 'QUEUED' + ORDER BY created_at + LIMIT 1 + """ + ).fetchone() + if not row: + return None + updated = connection.execute( + """ + UPDATE runs + SET status = 'PREPARING', started_at = COALESCE(started_at, ?), + heartbeat_at = ?, attempt_count = attempt_count + 1, + error = NULL + WHERE run_id = ? AND status = 'QUEUED' + """, + (claimed_at, claimed_at, row["run_id"]), + ) + if updated.rowcount != 1: + return None + self._event(connection, row["run_id"], "RUN_CLAIMED", {}) + claimed = connection.execute( + "SELECT * FROM runs WHERE run_id = ?", (row["run_id"],) + ).fetchone() + return dict(claimed) + + def recover_interrupted_runs(self) -> list[str]: + """Requeue runs left active when the single POC worker was restarted.""" + recovered: list[str] = [] + recovered_at = now() + with self.connect() as connection: + connection.execute("BEGIN IMMEDIATE") + rows = connection.execute( + """ + SELECT run_id, attempt_count, max_attempts + FROM runs + WHERE status IN ('PREPARING', 'RUNNING_AGENT', 'GRADING', 'UPLOADING') + ORDER BY created_at + """ + ).fetchall() + for row in rows: + retry = row["attempt_count"] < row["max_attempts"] + status = "QUEUED" if retry else "FAILED" + finished_at = None if retry else recovered_at + error = "worker restarted before run reached a terminal state" + connection.execute( + """ + UPDATE runs + SET status = ?, error = ?, heartbeat_at = ?, finished_at = ? + WHERE run_id = ? + """, + (status, error, recovered_at, finished_at, row["run_id"]), + ) + connection.execute( + """ + UPDATE instances + SET status = ?, error = ?, finished_at = ? + WHERE run_id = ? AND status = 'RUNNING' + """, + ( + "PENDING" if retry else "INFRA_FAILED", + error, + None if retry else recovered_at, + row["run_id"], + ), + ) + self._event( + connection, + row["run_id"], + "RUN_RECOVERED", + {"status": status, "previous_attempt": row["attempt_count"]}, + ) + recovered.append(row["run_id"]) + return recovered + + def transition( + self, + run_id: str, + status: str, + *, + error: str | None = None, + qwen_commit: str | None = None, + completed_instances: int | None = None, + resolved_instances: int | None = None, + ) -> None: + fields = ["status = ?", "heartbeat_at = ?", "error = ?"] + values: list[Any] = [status, now(), error] + if qwen_commit is not None: + fields.append("qwen_commit = ?") + values.append(qwen_commit) + if completed_instances is not None: + fields.append("completed_instances = ?") + values.append(completed_instances) + if resolved_instances is not None: + fields.append("resolved_instances = ?") + values.append(resolved_instances) + if status in TERMINAL_RUN_STATES: + fields.append("finished_at = ?") + values.append(now()) + values.append(run_id) + with self.connect() as connection: + connection.execute( + f"UPDATE runs SET {', '.join(fields)} WHERE run_id = ?", + values, + ) + self._event(connection, run_id, "RUN_STATUS", {"status": status}) + + def heartbeat(self, run_id: str) -> None: + for attempt in range(len(SQLITE_RETRY_DELAYS) + 1): + try: + with self.connect() as connection: + connection.execute( + "UPDATE runs SET heartbeat_at = ? WHERE run_id = ?", + (now(), run_id), + ) + return + except sqlite3.OperationalError: + if attempt == len(SQLITE_RETRY_DELAYS): + raise + delay = SQLITE_RETRY_DELAYS[attempt] + LOGGER.warning( + "SQLite heartbeat failed for %s; retrying in %.2fs", + run_id, + delay, + exc_info=True, + ) + time.sleep(delay) + + def update_instance( + self, + run_id: str, + instance_id: str, + status: str, + error: str | None = None, + ) -> None: + terminal = status in { + "RESOLVED", + "UNRESOLVED", + "AGENT_FAILED", + "INFRA_FAILED", + "TIMEOUT", + "CANCELED", + } + with self.connect() as connection: + connection.execute( + """ + UPDATE instances + SET status = ?, error = ?, + started_at = CASE + WHEN started_at IS NULL AND ? = 'RUNNING' THEN ? + ELSE started_at + END, + finished_at = CASE WHEN ? THEN ? ELSE finished_at END + WHERE run_id = ? AND instance_id = ? + """, + ( + status, + error, + status, + now(), + terminal, + now(), + run_id, + instance_id, + ), + ) + + def requeue_or_fail(self, run_id: str, error: str) -> str: + with self.connect() as connection: + row = connection.execute( + "SELECT attempt_count, max_attempts FROM runs WHERE run_id = ?", + (run_id,), + ).fetchone() + if row and row["attempt_count"] < row["max_attempts"]: + status = "QUEUED" + finished_at = None + else: + status = "FAILED" + finished_at = now() + connection.execute( + """ + UPDATE runs + SET status = ?, error = ?, heartbeat_at = ?, finished_at = ? + WHERE run_id = ? + """, + (status, error, now(), finished_at, run_id), + ) + self._event( + connection, + run_id, + "INFRA_FAILURE", + {"status": status, "error": error}, + ) + return status + + def cancel(self, run_id: str) -> bool: + with self.connect() as connection: + updated = connection.execute( + """ + UPDATE runs SET status = 'CANCELED', finished_at = ?, heartbeat_at = ? + WHERE run_id = ? AND status IN ('QUEUED', 'FAILED') + """, + (now(), now(), run_id), + ) + return updated.rowcount == 1 + + def retry(self, run_id: str) -> bool: + with self.connect() as connection: + updated = connection.execute( + """ + UPDATE runs + SET status = 'QUEUED', error = NULL, finished_at = NULL + WHERE run_id = ? AND status = 'FAILED' + """, + (run_id,), + ) + return updated.rowcount == 1 + + @staticmethod + def _event( + connection: sqlite3.Connection, + run_id: str, + event_type: str, + detail: dict[str, Any], + ) -> None: + connection.execute( + """ + INSERT INTO events (run_id, event_type, detail_json, created_at) + VALUES (?, ?, ?, ?) + """, + (run_id, event_type, json.dumps(detail, sort_keys=True), now()), + ) diff --git a/benchmark-service/qwen_benchmark/submit.py b/benchmark-service/qwen_benchmark/submit.py new file mode 100644 index 0000000000..446458a949 --- /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/suites.json b/benchmark-service/qwen_benchmark/suites.json new file mode 100644 index 0000000000..4791f03ec9 --- /dev/null +++ b/benchmark-service/qwen_benchmark/suites.json @@ -0,0 +1,33 @@ +{ + "swebench_verified_gold_smoke": { + "dataset": "princeton-nlp/SWE-bench_Verified", + "dataset_revision": "c104f840cc67f8b6eec6f759ebc8b2693d585d4a", + "instance_ids": ["sympy__sympy-20590"], + "runner_mode": "gold", + "grader_concurrency": 1, + "instance_timeout_seconds": 3600, + "publish": false + }, + "swebench_verified_qwen_smoke": { + "dataset": "princeton-nlp/SWE-bench_Verified", + "dataset_revision": "c104f840cc67f8b6eec6f759ebc8b2693d585d4a", + "instance_ids": ["sympy__sympy-20590"], + "runner_mode": "qwen", + "grader_concurrency": 1, + "instance_timeout_seconds": 3600, + "publish": false + }, + "swebench_verified_harbor_smoke": { + "dataset": "swe-bench/swe-bench-verified", + "dataset_revision": "2", + "harbor_dataset": "swe-bench/swe-bench-verified", + "harbor_dataset_version": "2", + "harbor_task_prefix": "swe-bench/", + "instance_ids": ["sympy__sympy-20590"], + "runner_mode": "harbor", + "grader_concurrency": 1, + "instance_timeout_seconds": 7200, + "harbor_max_turns": 200, + "publish": true + } +} diff --git a/benchmark-service/qwen_benchmark/worker.py b/benchmark-service/qwen_benchmark/worker.py new file mode 100644 index 0000000000..b382b5cd6c --- /dev/null +++ b/benchmark-service/qwen_benchmark/worker.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import json +import logging +import time +from collections.abc import Callable +from typing import Any + +from .artifacts import Artifacts +from .config import Settings, Suite, load_suites +from .harbor_runner import HarborRunner, qwen_version_from_ref +from .publisher import publish_release +from .runner import AgentError, InfrastructureError, RunResult, SwebenchRunner +from .store import Store + + +LOGGER = logging.getLogger(__name__) + + +class Worker: + def __init__( + self, + settings: Settings, + store: Store, + suites: dict[str, Suite], + runner_factory: Callable[[Callable[[], None]], SwebenchRunner] | None = None, + ): + self.settings = settings + self.store = store + self.suites = suites + self.runner_factory = runner_factory + + def _heartbeat(self, run_id: str) -> None: + try: + self.store.heartbeat(run_id) + except Exception: + # State storage must not terminate an in-flight agent or verifier. + # The next heartbeat/final transition can reconcile the run once the + # transient SQLite/filesystem problem clears. + LOGGER.exception( + "heartbeat unavailable for %s; benchmark continues", run_id + ) + + def run_once(self) -> bool: + run = self.store.claim_run() + if not run: + return False + run_id = run["run_id"] + suite = self.suites[run["suite"]] + artifacts = Artifacts(self.settings.artifact_root, run_id) + request = json.loads(run["request_json"]) + instances = self.store.get_instances(run_id) + + if self.runner_factory: + runner = self.runner_factory(lambda: self._heartbeat(run_id)) + elif suite["runner_mode"] == "harbor": + runner = HarborRunner(self.settings, lambda: self._heartbeat(run_id)) + else: + runner = SwebenchRunner(self.settings, lambda: self._heartbeat(run_id)) + try: + artifacts.write_json("request.json", request) + artifacts.write_json( + "manifest.json", + { + "run_id": run_id, + "qwen_ref": run["qwen_ref"], + "qwen_commit": request.get("qwen_commit"), + "qwen_version": ( + qwen_version_from_ref(run["qwen_ref"]) + if suite["runner_mode"] == "harbor" + else None + ), + "dataset": suite["dataset"], + "dataset_revision": suite["dataset_revision"], + "instance_ids": suite["instance_ids"], + "runner_mode": suite["runner_mode"], + }, + ) + self._write_status(artifacts, run_id) + request_commit = request.get("qwen_commit") + qwen_commit = request_commit or runner.resolve_qwen_commit( + run["qwen_ref"] + ) + self.store.transition(run_id, "RUNNING_AGENT", qwen_commit=qwen_commit) + for instance in instances: + self.store.update_instance(run_id, instance["instance_id"], "RUNNING") + self._write_status(artifacts, run_id) + + result = runner.run( + run_id, + qwen_commit, + suite, + artifacts, + on_grading=lambda: self._start_grading(artifacts, run_id), + qwen_ref=run["qwen_ref"], + ) + if result.completed != len(suite["instance_ids"]): + raise InfrastructureError( + "grader did not complete every manifest instance" + ) + if result.error_ids: + raise InfrastructureError( + f"grader returned error instances: {result.error_ids}" + ) + self._record_result(run_id, suite, result) + + self.store.transition( + run_id, + "UPLOADING", + completed_instances=result.completed, + resolved_instances=result.resolved, + ) + summary = self._summary(run_id, result) + artifacts.write_json("summary.json", summary) + self.store.transition( + run_id, + "SUCCEEDED", + completed_instances=result.completed, + resolved_instances=result.resolved, + ) + self._write_status(artifacts, run_id) + 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): + if instance["status"] == "RUNNING": + self.store.update_instance( + run_id, instance["instance_id"], "AGENT_FAILED", str(error) + ) + 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)) + if status == "FAILED": + for instance in self.store.get_instances(run_id): + if instance["status"] == "RUNNING": + self.store.update_instance( + run_id, + instance["instance_id"], + "INFRA_FAILED", + str(error), + ) + artifacts.write_json( + "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( + "error.json", {"class": "unexpected", "error": str(error)} + ) + 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", + run_id, + ) + finally: + try: + artifacts.write_checksums() + except OSError: + LOGGER.exception("could not write artifact checksums for %s", run_id) + return True + + def _start_grading(self, artifacts: Artifacts, run_id: str) -> None: + try: + self.store.transition(run_id, "GRADING") + self._write_status(artifacts, run_id) + except Exception: + LOGGER.exception( + "could not persist GRADING state for %s; benchmark continues", run_id + ) + + def _record_result(self, run_id: str, suite: Suite, result: RunResult) -> None: + resolved = set(result.resolved_ids) + unresolved = set(result.unresolved_ids) + errors = set(result.error_ids) + for instance_id in suite["instance_ids"]: + if instance_id in resolved: + status = "RESOLVED" + elif instance_id in unresolved: + status = "UNRESOLVED" + elif instance_id in errors: + status = "INFRA_FAILED" + else: + status = "INFRA_FAILED" + self.store.update_instance(run_id, instance_id, status) + + def _summary(self, run_id: str, result: RunResult) -> 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": result.completed, + "resolved_instances": result.resolved, + "unresolved_instances": len(result.unresolved_ids), + "error_instances": len(result.error_ids), + "resolved_ids": result.resolved_ids, + "unresolved_ids": result.unresolved_ids, + "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}") + qwen_version = None + if run["runner_mode"] == "harbor": + try: + qwen_version = qwen_version_from_ref(run["qwen_ref"]) + except AgentError: + pass + return { + "run_id": run_id, + "repository": run["repository"], + "qwen_ref": run["qwen_ref"], + "qwen_commit": run["qwen_commit"], + "qwen_version": qwen_version, + "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 + if not self.suites[current["suite"]]["publish"]: + 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: + artifacts.write_json( + "status.json", + { + "run_id": run_id, + "status": run["status"], + "expected_instances": run["expected_instances"], + "completed_instances": run["completed_instances"], + "resolved_instances": run["resolved_instances"], + "heartbeat_at": run["heartbeat_at"], + "error": run["error"], + }, + ) + + +def main() -> None: + 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() + recovered = store.recover_interrupted_runs() + if recovered: + LOGGER.warning("recovered interrupted benchmark runs: %s", recovered) + worker = Worker(settings, store, load_suites()) + while True: + if not worker.run_once(): + time.sleep(settings.poll_seconds) diff --git a/benchmark-service/tests/test_harbor_runner.py b/benchmark-service/tests/test_harbor_runner.py new file mode 100644 index 0000000000..655fbb3974 --- /dev/null +++ b/benchmark-service/tests/test_harbor_runner.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from qwen_benchmark.config import Settings, load_suites +from qwen_benchmark.harbor_runner import HarborRunner, qwen_version_from_ref +from qwen_benchmark.runner import AgentError + + +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, + harbor_binary=tmp_path / "harbor", + harbor_jobs_root=tmp_path / "harbor-jobs", + benchmark_model="test-model", + npm_wait_seconds=0, + ) + + +def test_qwen_version_from_release_ref() -> None: + assert qwen_version_from_ref("v0.19.7") == "0.19.7" + assert qwen_version_from_ref("0.20.0-beta.1") == "0.20.0-beta.1" + with pytest.raises(AgentError): + qwen_version_from_ref("main") + + +def test_parse_harbor_result_and_validate_version(tmp_path: Path) -> None: + runner = HarborRunner(settings(tmp_path), lambda: None) + suite = load_suites()["swebench_verified_harbor_smoke"] + jobs_root = tmp_path / "jobs" + trial = jobs_root / "sympy__sympy-20590" / "sympy__sympy-20590__abc123" + trial.mkdir(parents=True) + (trial / "result.json").write_text( + json.dumps( + { + "task_name": "swe-bench/sympy__sympy-20590", + "agent_info": {"name": "qwen-coder", "version": "0.19.7"}, + "exception_info": None, + "verifier_result": {"rewards": {"reward": 1}}, + } + ), + encoding="utf-8", + ) + work_dir = tmp_path / "work" + work_dir.mkdir() + + result = runner._parse_results(jobs_root, suite, "0.19.7", work_dir) + assert result.completed == 1 + assert result.resolved == 1 + assert result.resolved_ids == ["sympy__sympy-20590"] + assert result.error_ids == [] + + payload = json.loads(result.report_path.read_text()) + assert payload["qwen_version"] == "0.19.7" + assert payload["trials"][0]["reward"] == 1 + + +def test_parse_harbor_result_rejects_wrong_qwen_version(tmp_path: Path) -> None: + runner = HarborRunner(settings(tmp_path), lambda: None) + suite = load_suites()["swebench_verified_harbor_smoke"] + jobs_root = tmp_path / "jobs" + trial = jobs_root / "sympy__sympy-20590" / "trial" + trial.mkdir(parents=True) + (trial / "result.json").write_text( + json.dumps( + { + "task_name": "sympy__sympy-20590", + "agent_info": {"name": "qwen-coder", "version": "latest"}, + "exception_info": None, + "verifier_result": {"rewards": {"reward": 0}}, + } + ), + encoding="utf-8", + ) + work_dir = tmp_path / "work" + work_dir.mkdir() + + with pytest.raises(AgentError, match="version mismatch"): + runner._parse_results(jobs_root, suite, "0.19.7", work_dir) + + +def test_npm_metadata_accepts_flattened_dist_fields( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runner = HarborRunner(settings(tmp_path), lambda: None) + + class Result: + returncode = 0 + stdout = json.dumps( + { + "version": "0.19.7", + "dist.integrity": "sha512-test", + "dist.tarball": "https://registry.example/qwen-code-0.19.7.tgz", + } + ) + stderr = "" + + monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: Result()) + metadata = runner._wait_for_npm_release("0.19.7") + assert metadata["dist.integrity"] == "sha512-test" diff --git a/benchmark-service/tests/test_publisher.py b/benchmark-service/tests/test_publisher.py new file mode 100644 index 0000000000..1daf832f10 --- /dev/null +++ b/benchmark-service/tests/test_publisher.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest + +from qwen_benchmark.config import Settings +from qwen_benchmark.publisher import END_MARKER, START_MARKER, publish_release + + +def test_publish_updates_the_triggering_release(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, str, dict | None]] = [] + + def response(method: str, url: str, **kwargs): + calls.append((method, url, kwargs.get("json"))) + if method == "get": + return httpx.Response( + 200, + json={ + "body": ( + f"Original notes\n\n{START_MARKER}\nold result\n" + f"{END_MARKER}\n\nPostscript" + ), + "tag_name": "v0.19.7", + "prerelease": True, + }, + 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)) + run = { + "run_id": "qwen-bench-test", + "repository": "QwenLM/qwen-code", + "qwen_ref": "v0.19.7", + "qwen_commit": "a" * 40, + "suite": "swebench_verified_harbor_smoke", + "status": "SUCCEEDED", + "request_json": json.dumps( + {"release_id": 123, "trigger": "workflow_dispatch"} + ), + } + summary = { + "run_id": "qwen-bench-test", + "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_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 "`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 "old result" not in calls[1][2]["body"] + assert calls[1][2]["body"].endswith("\n\nPostscript") + 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", + "tag_name": "v0.19.7", + "prerelease": False, + } + 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_ref": "v0.19.7", + "qwen_commit": "a" * 40, + "suite": "swebench_verified_harbor_smoke", + "status": "FAILED", + "request_json": json.dumps({"release_id": 123, "trigger": "release"}), + } + 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 + + +def test_publish_rejects_a_release_for_another_tag( + 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", + ) + response = httpx.Response( + 200, + json={"body": "", "tag_name": "v0.19.6", "prerelease": True}, + request=httpx.Request( + "GET", "https://api.github.com/repos/QwenLM/qwen-code/releases/123" + ), + ) + monkeypatch.setattr( + "qwen_benchmark.publisher.httpx.get", lambda *args, **kwargs: response + ) + monkeypatch.setattr( + "qwen_benchmark.publisher.httpx.patch", + lambda *args, **kwargs: pytest.fail("mismatched Release must not be patched"), + ) + run = { + "repository": "QwenLM/qwen-code", + "qwen_ref": "v0.19.7", + "status": "SUCCEEDED", + "request_json": json.dumps( + {"release_id": 123, "trigger": "workflow_dispatch"} + ), + } + + with pytest.raises(ValueError, match="release tag does not match"): + publish_release(settings, run, {}) diff --git a/benchmark-service/tests/test_submit.py b/benchmark-service/tests/test_submit.py new file mode 100644 index 0000000000..71ae4bd1c4 --- /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 new file mode 100644 index 0000000000..2584207bb5 --- /dev/null +++ b/benchmark-service/tests/test_worker.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import json +import hashlib +import sqlite3 +from pathlib import Path + +from qwen_benchmark.config import Settings, load_suites +from qwen_benchmark.models import RunRequest +from qwen_benchmark.runner import AgentError, RunResult +from qwen_benchmark.store import Store +from qwen_benchmark.worker import Worker + + +class FakeRunner: + def __init__(self, heartbeat): + self.heartbeat = heartbeat + + def resolve_qwen_commit(self, qwen_ref: str) -> str: + assert qwen_ref == "HEAD" + return "a" * 40 + + def run( + self, + run_id, + qwen_commit, + suite, + artifacts, + on_grading=None, + qwen_ref=None, + ) -> RunResult: + assert qwen_ref == "HEAD" + self.heartbeat() + if on_grading: + on_grading() + report = artifacts.write_json( + "grader/evaluation-report.json", + { + "completed_instances": 1, + "resolved_instances": 1, + "resolved_ids": ["sympy__sympy-20590"], + "unresolved_ids": [], + "error_ids": [], + }, + ) + return RunResult(1, 1, ["sympy__sympy-20590"], [], [], report) + + +class FlakyHeartbeatStore(Store): + def __init__(self, database_path: Path): + super().__init__(database_path) + self.heartbeat_calls = 0 + + def heartbeat(self, run_id: str) -> None: + self.heartbeat_calls += 1 + if self.heartbeat_calls == 1: + raise sqlite3.OperationalError("unable to open database file") + 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, 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-test-1") + published = [] + monkeypatch.setattr( + "qwen_benchmark.worker.publish_release", + lambda *args: published.append(args), + ) + + worker = Worker( + settings, + store, + suites, + runner_factory=lambda heartbeat: FakeRunner(heartbeat), + ) + assert worker.run_once() is True + assert worker.run_once() is False + + final = store.get_run(run["run_id"]) + assert final is not None + assert final["status"] == "SUCCEEDED" + assert final["resolved_instances"] == 1 + + instance = store.get_instances(run["run_id"])[0] + assert instance["status"] == "RESOLVED" + + 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) + actual = hashlib.sha256( + (artifact_root / relative_path).read_bytes() + ).hexdigest() + assert actual == expected + assert published == [] + + +def test_transient_heartbeat_failure_does_not_abort_runner(tmp_path: Path) -> 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=None, + harbor_jobs_root=tmp_path / "harbor-jobs", + ) + settings.prepare_directories() + store = FlakyHeartbeatStore(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-test-flaky") + worker = Worker( + settings, + store, + suites, + runner_factory=lambda heartbeat: FakeRunner(heartbeat), + ) + + assert worker.run_once() is True + final = store.get_run(run["run_id"]) + assert final is not None + assert final["status"] == "SUCCEEDED" + 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="v0.20.0-nightly.20260722.b98306b7e", + suite="swebench_verified_harbor_smoke", + trigger="workflow_dispatch", + release_id=123, + ) + 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_invalid_harbor_release_ref_becomes_a_terminal_failure( + tmp_path: Path, +) -> 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=None, + harbor_jobs_root=tmp_path / "harbor-jobs", + ) + settings.prepare_directories() + store = Store(settings.database_path) + store.initialize() + suites = load_suites() + request = RunRequest( + qwen_ref="main", + suite="swebench_verified_harbor_smoke", + trigger="manual", + ) + run, _ = store.create_run(request, suites[request.suite], "invalid-ref") + worker = Worker(settings, store, suites) + + assert worker.run_once() is True + final = store.get_run(run["run_id"]) + assert final is not None + assert final["status"] == "FAILED" + assert "release tag" in final["error"] + + +def test_publisher_error_is_included_in_artifact_checksums( + 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() + suites["swebench_verified_gold_smoke"]["publish"] = True + request = RunRequest( + qwen_ref="HEAD", + suite="swebench_verified_gold_smoke", + trigger="manual", + ) + run, _ = store.create_run(request, suites[request.suite], "publisher-error") + + def fail_publish(*args) -> None: + raise RuntimeError("GitHub unavailable") + + monkeypatch.setattr("qwen_benchmark.worker.publish_release", fail_publish) + worker = Worker( + settings, + store, + suites, + runner_factory=lambda heartbeat: FakeRunner(heartbeat), + ) + + assert worker.run_once() is True + artifact_root = settings.artifact_root / run["run_id"] + assert (artifact_root / "publisher-error.json").is_file() + checksums = (artifact_root / "checksums.sha256").read_text() + assert "publisher-error.json" in checksums + + +def test_recover_interrupted_run_requeues_with_attempt_remaining( + tmp_path: Path, +) -> None: + store = Store(tmp_path / "state/benchmark.db") + 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-recovery") + claimed = store.claim_run() + assert claimed is not None + store.transition(run["run_id"], "GRADING") + store.update_instance(run["run_id"], "sympy__sympy-20590", "RUNNING") + + assert store.recover_interrupted_runs() == [run["run_id"]] + recovered = store.get_run(run["run_id"]) + assert recovered is not None + assert recovered["status"] == "QUEUED" + assert recovered["attempt_count"] == 1 + instance = store.get_instances(run["run_id"])[0] + assert instance["status"] == "PENDING" diff --git a/docs/design/qwen-code-benchmark-release-pipeline.md b/docs/design/qwen-code-benchmark-release-pipeline.md new file mode 100644 index 0000000000..3bd2825bf9 --- /dev/null +++ b/docs/design/qwen-code-benchmark-release-pipeline.md @@ -0,0 +1,46 @@ +# Qwen Code Benchmark Release Pipeline + +## Goal + +Publish a reproducible SWE-bench result for each stable Qwen Code release without exposing benchmark infrastructure or raw model traces to the public internet. A small manual smoke suite remains available for prerelease validation. + +## Architecture + +```text +GitHub release.published / workflow_dispatch + -> repository-scoped self-hosted ECS runner + -> root-owned qwen-benchmark-dispatch + -> qwen-benchmark-submit + -> SQLite run, instance, and event state + -> systemd qwen-benchmark-worker + -> Harbor Docker testbed with the requested Qwen Code npm version + -> SWE-bench verifier + -> checksummed private artifacts + -> public summary on the triggering GitHub Release +``` + +The GitHub job only validates immutable release metadata and queues work. It does not wait for the multi-minute or multi-hour benchmark. The worker owns execution, heartbeat, retry, artifact validation, terminal state, and Release publication. + +The runner and worker share one ECS host in the POC. The dispatcher is the only sudo command granted to the unprivileged runner account. There is no public FastAPI service, HTTP listener, Nginx route, release poller, or commit Check Run. + +## Trigger and version identity + +Stable `release.published` events run the default Harbor smoke suite. Prereleases are excluded from automatic execution. Maintainers can manually dispatch an allowlisted suite against an existing prerelease and optionally provide that prerelease's numeric Release ID for result publication. + +The workflow resolves annotated tags until it reaches a 40-character commit SHA. Each request records the repository, tag, immutable commit, suite, dataset revision, GitHub run identity, and Release ID. Harbor must report the exact Qwen Code npm version derived from the requested tag. + +## State, completion, and retry + +SQLite is both the single-node queue and source of truth. Runs move through `QUEUED`, `PREPARING`, `RUNNING_AGENT`, `GRADING`, `UPLOADING`, and one terminal state. Every manifest instance must have one terminal result before a run can succeed. + +The worker emits heartbeat and event records. A restart can recover an interrupted infrastructure attempt. Infrastructure failures may retry once; a valid unresolved answer, failed repository tests, or a normal agent timeout is a benchmark outcome and is not retried. + +## Publication and data boundary + +Only suites explicitly marked publishable can update GitHub. The publisher verifies that the Release tag matches the benchmarked ref and restricts manual publication to prereleases. Successful runs replace the paired-marker benchmark table on the triggering Release while preserving content before and after it. The table contains dataset and revision, suite, evaluation method, completed/resolved/unresolved/infrastructure-error counts, score, exact Qwen Code version and commit, and ECS run ID. Failed terminal runs publish status and counts without a score. + +Only the aggregate public summary is written to GitHub. Model keys, GitHub tokens, raw trajectories, patches, Docker logs, and internal paths stay on the ECS host or private object storage. Run artifacts include a SHA-256 manifest and a separate publication-error record only when Release publication fails. + +## POC scope and production path + +The POC intentionally uses one worker and SQLite. Production scale should move the same run/instance contract to a shared scheduler and object store without changing the GitHub trigger or public result schema. Multi-case failure thresholds, durable publication retry, image mirroring, secret injection that never expands credentials into process arguments, and concurrent resource classes remain production follow-ups.