Skip to content

Commit b315c71

Browse files
authored
Merge pull request #14 from Agentiix/test/nightly-swebench-verified-gt
Make nightly SWE-bench CI fail fast
2 parents 9ae9134 + e158465 commit b315c71

9 files changed

Lines changed: 202 additions & 27 deletions

File tree

.github/workflows/nightly-ci.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
runs-on: ubuntu-24.04
1919
timeout-minutes: 360
2020
strategy:
21-
fail-fast: false
21+
fail-fast: true
2222
matrix:
2323
shard_index:
2424
- 0
@@ -71,10 +71,9 @@ jobs:
7171
env:
7272
SHARD_INDEX: ${{ matrix.shard_index }}
7373
run: |
74-
uv run python -m runner \
75-
--ground-truth \
76-
--fail-on-unresolved \
74+
uv run python -m ci_runner \
7775
--bundle-image "$BUNDLE_IMAGE" \
76+
--docker-platform linux/amd64 \
7877
--num-shards "$NUM_SHARDS" \
7978
--shard-index "$SHARD_INDEX" \
8079
--out "runs/ground-truth-shard-${SHARD_INDEX}"

docs/quickstart.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ Under the hood, `DockerDeployment.create` does:
167167
docker create --name agentix-runtime-<hash> hello-agentix:0.1.0
168168

169169
# Per sandbox — task image plus the runtime's /nix as a shared volume.
170-
docker run -d --name <sid> --network host \
170+
docker run -d --name <sid> -p 127.0.0.1:<port>:<port> \
171171
-e AGENTIX_BIND_PORT=<port> \
172172
--volumes-from agentix-runtime-<hash>:ro \
173173
--entrypoint /nix/runtime/bin/agentix-server \

examples/eval-cc-swe/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export OPENAI_MODEL=gpt-4o-mini
104104
python -m runner --limit 5
105105
# or specific instances:
106106
python -m runner --instance-id django__django-11099 --instance-id sympy__sympy-20212
107+
108+
# CI-only ground-truth check: exits on the first unresolved instance.
109+
python -m ci_runner --num-shards 20 --shard-index 0 --out runs/ground-truth-shard-0
107110
```
108111

109112
Runner flags (all optional unless noted):

examples/eval-cc-swe/ci_runner.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Fail-fast SWE-bench Verified ground-truth runner for nightly CI.
2+
3+
This is intentionally narrower than ``runner.py``:
4+
5+
* ground-truth patches only, no agent phase or OpenAI configuration
6+
* sequential execution inside each shard
7+
* exit immediately on the first unresolved instance, patch-apply failure,
8+
skip, or runner exception
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import asyncio
15+
import json
16+
import logging
17+
import sys
18+
from pathlib import Path
19+
from typing import Any
20+
21+
from datasets import load_dataset
22+
from runner import _selected_instances, _should_fail, evaluate_ground_truth_one
23+
24+
logger = logging.getLogger("eval_cc_swe.ci_runner")
25+
26+
27+
def _write_run_summary(out_dir: Path, summaries: list[dict[str, Any]]) -> None:
28+
out_dir.mkdir(parents=True, exist_ok=True)
29+
failures = [s for s in summaries if _should_fail(s)]
30+
(out_dir / "summary.json").write_text(json.dumps(summaries, indent=2))
31+
if failures:
32+
(out_dir / "failures.json").write_text(json.dumps(failures, indent=2))
33+
34+
35+
def _failure_reason(summary: dict[str, Any]) -> str:
36+
if summary.get("error"):
37+
return str(summary["error"])
38+
if summary.get("skipped"):
39+
return f"skipped: {summary['skipped']}"
40+
if not summary.get("patch_applied"):
41+
return "patch did not apply"
42+
if not summary.get("resolved"):
43+
ftp = summary.get("fail_to_pass", {}) or {}
44+
ptp = summary.get("pass_to_pass", {}) or {}
45+
return (
46+
"unresolved: "
47+
f"FAIL_TO_PASS failures={len(ftp.get('failure', []))}, "
48+
f"PASS_TO_PASS failures={len(ptp.get('failure', []))}"
49+
)
50+
return "unknown failure"
51+
52+
53+
async def run_ci(args: argparse.Namespace) -> int:
54+
ds = load_dataset(args.dataset, split=args.split)
55+
instances = _selected_instances(
56+
ds,
57+
instance_ids=args.instance_id,
58+
limit=args.limit,
59+
ground_truth=True,
60+
num_shards=args.num_shards,
61+
shard_index=args.shard_index,
62+
)
63+
if not instances:
64+
print("error: no instances selected", file=sys.stderr)
65+
return 2
66+
67+
out_dir = Path(args.out)
68+
out_dir.mkdir(parents=True, exist_ok=True)
69+
print(f"selected {len(instances)} instance(s) (shard {args.shard_index + 1}/{args.num_shards}, fail_fast=True)")
70+
71+
summaries: list[dict[str, Any]] = []
72+
for offset, inst in enumerate(instances, start=1):
73+
iid = inst["instance_id"]
74+
print(f"[ci] {offset}/{len(instances)} {iid}")
75+
try:
76+
summary = await evaluate_ground_truth_one(
77+
inst,
78+
bundle_image=args.bundle_image,
79+
swebench_namespace=args.swebench_namespace,
80+
swebench_tag=args.swebench_tag,
81+
arch=args.arch,
82+
docker_platform=args.docker_platform,
83+
eval_timeout=args.eval_timeout,
84+
out_dir=out_dir,
85+
)
86+
except Exception as exc:
87+
logger.exception("[%s] crashed", iid)
88+
summary = {
89+
"instance_id": iid,
90+
"mode": "ground_truth",
91+
"error": f"{type(exc).__name__}: {exc}",
92+
}
93+
(out_dir / f"{iid}.json").write_text(json.dumps(summary, indent=2))
94+
95+
summaries.append(summary)
96+
_write_run_summary(out_dir, summaries)
97+
98+
if _should_fail(summary):
99+
print(
100+
f"[ci] fail-fast: {iid} failed: {_failure_reason(summary)}",
101+
file=sys.stderr,
102+
)
103+
return 1
104+
105+
print(f"\n{len(summaries)}/{len(summaries)} resolved")
106+
return 0
107+
108+
109+
def build_parser() -> argparse.ArgumentParser:
110+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
111+
parser.add_argument("--bundle-image", default="eval-cc-swe:0.2.0")
112+
parser.add_argument("--swebench-namespace", default="swebench")
113+
parser.add_argument("--swebench-tag", default="latest")
114+
parser.add_argument("--arch", default="x86_64", choices=["x86_64", "arm64"])
115+
parser.add_argument(
116+
"--docker-platform",
117+
default=None,
118+
help="Docker platform for the runtime and task containers, e.g. linux/amd64.",
119+
)
120+
parser.add_argument("--dataset", default="princeton-nlp/SWE-bench_Verified")
121+
parser.add_argument("--split", default="test")
122+
parser.add_argument(
123+
"--limit",
124+
type=int,
125+
default=None,
126+
help="Number of dataset rows to consider before sharding. Defaults to all rows.",
127+
)
128+
parser.add_argument("--instance-id", action="append", default=None)
129+
parser.add_argument("--num-shards", type=int, default=1)
130+
parser.add_argument("--shard-index", type=int, default=0)
131+
parser.add_argument("--eval-timeout", type=float, default=1800)
132+
parser.add_argument("--out", default="runs/ci-ground-truth")
133+
return parser
134+
135+
136+
def main(argv: list[str] | None = None) -> int:
137+
logging.basicConfig(
138+
level=logging.INFO,
139+
format="%(asctime)s [%(name)s] %(message)s",
140+
)
141+
args = build_parser().parse_args(argv)
142+
return asyncio.run(run_ci(args))
143+
144+
145+
if __name__ == "__main__":
146+
raise SystemExit(main(sys.argv[1:]))

examples/eval-cc-swe/default.nix

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,11 @@ let
5050
in
5151
pkgs.symlinkJoin {
5252
name = "eval-cc-swe-deps";
53-
paths = [ claude pkgs.git ];
53+
paths = [
54+
claude
55+
pkgs.git
56+
# Binary wheels used by the SWE-bench harness stack (numpy, pandas,
57+
# pyarrow, scikit-learn) need libstdc++ at runtime.
58+
pkgs.stdenv.cc.cc.lib
59+
];
5460
}

examples/eval-cc-swe/pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,8 @@ agentix-bridge = { path = "../../plugins/abridge", editable = true }
3030
agentix-deployment-docker = { path = "../../plugins/deployment-docker", editable = true }
3131
agentix-runtime-basic = { path = "../../plugins/runtime-basic", editable = true }
3232

33+
[tool.agentix]
34+
nix = "default.nix"
35+
3336
[tool.hatch.build.targets.wheel]
34-
only-include = ["cc.py", "swe.py", "runner.py"]
37+
only-include = ["cc.py", "swe.py", "runner.py", "ci_runner.py"]

examples/eval-cc-swe/runner.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,6 @@ async def _run_eval_phase(
125125
) -> swe.EvalResult:
126126
async with session(DockerDeployment(), cfg) as sandbox:
127127
async with RuntimeClient(sandbox.runtime_url) as c:
128-
cleaned = await c.remote(
129-
swe.clean,
130-
workdir=WORKDIR,
131-
base_commit=inst["base_commit"],
132-
)
133-
if not cleaned.ok:
134-
tail = cleaned.log[-1000:]
135-
message = f"failed to reset {WORKDIR} to {inst['base_commit']}:\n{tail}"
136-
raise RuntimeError(message)
137128
return await c.remote(
138129
swe.eval,
139130
instance=inst,
@@ -149,6 +140,7 @@ async def evaluate_ground_truth_one(
149140
swebench_namespace: str,
150141
swebench_tag: str,
151142
arch: str,
143+
docker_platform: str | None,
152144
eval_timeout: float,
153145
out_dir: Path,
154146
) -> dict:
@@ -167,7 +159,7 @@ async def evaluate_ground_truth_one(
167159
(out_dir / f"{iid}.json").write_text(json.dumps(summary, indent=2))
168160
return summary
169161

170-
cfg = SandboxConfig(image=base_image, runtime_image=bundle_image)
162+
cfg = SandboxConfig(image=base_image, runtime_image=bundle_image, platform=docker_platform)
171163
started = time.time()
172164
print(f"[{iid}] ground-truth eval sandbox: {base_image}")
173165
print(f"[{iid}] patch_bytes={len(patch)}")
@@ -177,6 +169,8 @@ async def evaluate_ground_truth_one(
177169
patch=patch,
178170
eval_timeout=eval_timeout,
179171
)
172+
(out_dir / f"{iid}.apply.log").write_text(ev.apply_log)
173+
(out_dir / f"{iid}.test.log").write_text(ev.test_log)
180174

181175
summary = {
182176
"instance_id": iid,
@@ -209,6 +203,7 @@ async def solve_one(
209203
swebench_namespace: str,
210204
swebench_tag: str,
211205
arch: str,
206+
docker_platform: str | None,
212207
openai_base_url: str,
213208
openai_api_key: str,
214209
upstream_model: str,
@@ -226,7 +221,7 @@ async def solve_one(
226221
arch=arch,
227222
)
228223
out_dir.mkdir(parents=True, exist_ok=True)
229-
cfg = SandboxConfig(image=base_image, runtime_image=bundle_image)
224+
cfg = SandboxConfig(image=base_image, runtime_image=bundle_image, platform=docker_platform)
230225

231226
print(f"[{iid}] agent sandbox: {base_image}")
232227
started = time.time()
@@ -256,6 +251,8 @@ async def solve_one(
256251
patch=patch,
257252
eval_timeout=eval_timeout,
258253
)
254+
(out_dir / f"{iid}.apply.log").write_text(ev.apply_log)
255+
(out_dir / f"{iid}.test.log").write_text(ev.test_log)
259256

260257
summary = {
261258
"instance_id": iid,
@@ -322,6 +319,11 @@ async def main(argv: list[str] | None = None) -> int:
322319
parser.add_argument("--swebench-namespace", default="swebench")
323320
parser.add_argument("--swebench-tag", default="latest")
324321
parser.add_argument("--arch", default="x86_64", choices=["x86_64", "arm64"])
322+
parser.add_argument(
323+
"--docker-platform",
324+
default=None,
325+
help="Docker platform for the runtime and task containers, e.g. linux/amd64.",
326+
)
325327
parser.add_argument("--dataset", default="princeton-nlp/SWE-bench_Verified")
326328
parser.add_argument("--split", default="test")
327329
parser.add_argument(
@@ -408,6 +410,7 @@ async def main(argv: list[str] | None = None) -> int:
408410
swebench_namespace=args.swebench_namespace,
409411
swebench_tag=args.swebench_tag,
410412
arch=args.arch,
413+
docker_platform=args.docker_platform,
411414
eval_timeout=args.eval_timeout,
412415
out_dir=out_dir,
413416
)
@@ -418,6 +421,7 @@ async def main(argv: list[str] | None = None) -> int:
418421
swebench_namespace=args.swebench_namespace,
419422
swebench_tag=args.swebench_tag,
420423
arch=args.arch,
424+
docker_platform=args.docker_platform,
421425
openai_base_url=args.openai_base_url,
422426
openai_api_key=args.openai_api_key,
423427
upstream_model=args.upstream_model,

plugins/deployment-docker/agentix/deployment/docker.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@
2323
2424
Sandbox create:
2525
docker create [--platform <platform>] --name <carrier> <runtime_image>
26-
docker run [--platform <platform>] -d --name <sid> --network host \\
26+
docker run [--platform <platform>] -d --name <sid> \\
27+
-p 127.0.0.1:<port>:<port> \\
2728
-e AGENTIX_BIND_PORT=<port> \\
2829
--volumes-from <carrier>:ro \\
2930
--entrypoint /bin/sh \\
3031
<image> -c '<inject runtime env; exec agentix-server>'
3132
3233
`agentix-server` binds to the port from `AGENTIX_BIND_PORT`. We pick
33-
a free host port, pass it through, and health-check `/health` on it.
34+
a free host port, publish the same port to loopback, and health-check
35+
`/health` on it.
3436
"""
3537

3638
from __future__ import annotations
@@ -81,7 +83,8 @@
8183

8284
async def _docker(*args: str, check: bool = True) -> tuple[int, bytes, bytes]:
8385
proc = await asyncio.create_subprocess_exec(
84-
"docker", *args,
86+
"docker",
87+
*args,
8588
stdout=asyncio.subprocess.PIPE,
8689
stderr=asyncio.subprocess.PIPE,
8790
)
@@ -143,13 +146,18 @@ async def create(self, config: SandboxConfig) -> Sandbox:
143146
"run",
144147
*platform_args,
145148
"-d",
146-
"--name", sandbox_id,
147-
"--network", "host",
149+
"--name",
150+
sandbox_id,
151+
"-p",
152+
f"127.0.0.1:{port}:{port}",
148153
*env_args,
149-
"--volumes-from", f"{carrier}:ro",
150-
"--entrypoint", _RUNTIME_ENTRYPOINT,
154+
"--volumes-from",
155+
f"{carrier}:ro",
156+
"--entrypoint",
157+
_RUNTIME_ENTRYPOINT,
151158
config.image,
152-
"-c", _RUNTIME_BOOTSTRAP,
159+
"-c",
160+
_RUNTIME_BOOTSTRAP,
153161
)
154162

155163
self._ports[sandbox_id] = port
@@ -180,7 +188,11 @@ async def get(self, sandbox_id: SandboxId) -> SandboxInfo:
180188
if port is None:
181189
raise KeyError(f"Sandbox not found: {sandbox_id}")
182190
rc, stdout, _ = await _docker(
183-
"inspect", "-f", "{{.State.Status}}", sandbox_id, check=False,
191+
"inspect",
192+
"-f",
193+
"{{.State.Status}}",
194+
sandbox_id,
195+
check=False,
184196
)
185197
status = stdout.decode().strip() if rc == 0 else "unknown"
186198
return SandboxInfo(

tests/test_docker_deployment.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ async def fake_wait_healthy(self: DockerDeployment, port: int) -> None:
4848

4949
assert create_call[1:3] == ("--platform", "linux/amd64")
5050
assert run_call[1:3] == ("--platform", "linux/amd64")
51+
assert run_call[run_call.index("-p") + 1] == "127.0.0.1:18000:18000"
52+
assert "--network" not in run_call
5153
assert run_call[-3:] == (
5254
"python:3.13-slim",
5355
"-c",

0 commit comments

Comments
 (0)