Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions vero/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ __pycache__/
*.egg-info/
dist/
build/
# ...but the harbor compiler package is source, not a packaging artifact:
!src/vero/harbor/build/

# Testing
.pytest_cache/
Expand Down
6 changes: 6 additions & 0 deletions vero/src/vero/harbor/build/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""The `vero harbor build` compiler: BuildConfig -> a runnable Harbor task dir."""

from vero.harbor.build.compiler import compile_task
from vero.harbor.build.config import BuildConfig

__all__ = ["BuildConfig", "compile_task"]
296 changes: 296 additions & 0 deletions vero/src/vero/harbor/build/compiler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
"""The `vero harbor build` compiler: BuildConfig -> a runnable Harbor task dir.

Emits the environment (optimizer workbench `main` + eval `eval-sidecar`), the
protocol (instruction.md), the verifier (tests/test.sh -> `vero harbor finalize`),
and bakes the ServeConfig + dataset + baseline repo + vero source. The result runs
with `harbor run -p <task-dir> -a <optimizer> -m <model> -e docker`.
"""

from __future__ import annotations

import logging
import re
import shutil
import subprocess
from pathlib import Path

from jinja2 import Environment, FileSystemLoader

from vero.harbor.build.config import BuildConfig

logger = logging.getLogger(__name__)

_TEMPLATES = Path(__file__).parent / "templates"

# Container paths (must match the templates).
VERO_DIR = "/opt/vero"
AGENT_BASELINE = "/opt/agent-baseline" # sidecar engine workspace
WORK_AGENT = "/work/agent" # shared agent repo (main rw, sidecar ro)
VERO_HOME = "/opt/vero_home"
INNER_TASK = "/opt/inner-task" # Mode B: baked inner Harbor task (the protected benchmark)
SERVE_JSON = "/opt/serve.json"
ADMIN_VOLUME = "/state/admin"
AGENT_VOLUME = "/state/agent-results"
TOKEN_PATH = "/state/token/admin.token"
SESSION_ID = "trial"

# vero source items copied into the build context (enough to `uv pip install`).
_VERO_COPY = ["pyproject.toml", "README.md", "uv.lock", "src"]


def _render(env: Environment, template_name: str, dest: Path, **ctx) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(env.get_template(template_name).render(**ctx))


def _copy_vero_source(vero_root: Path, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
for item in _VERO_COPY:
src = vero_root / item
if not src.exists():
continue
if src.is_dir():
shutil.copytree(src, dest / item, dirs_exist_ok=True)
else:
shutil.copy2(src, dest / item)


def _rewrite_vero_source_path(pyproject: Path) -> None:
"""Point a relative `scale-vero` path dependency at the baked /opt/vero so it
resolves regardless of where the repo (or a temp worktree of it) lives."""
if not pyproject.exists():
return
text = pyproject.read_text()
new = re.sub(
r'(scale-vero\s*=\s*\{[^}]*?path\s*=\s*")[^"]*(")',
rf"\g<1>{VERO_DIR}\g<2>",
text,
)
if new != text:
pyproject.write_text(new)
logger.info("Rewrote scale-vero source path -> %s", VERO_DIR)


def _prepare_baseline_repo(agent_repo: Path, dest: Path) -> str:
"""Materialize the target repo at HEAD into a clean standalone git repo
(vero path rewritten) and return its commit sha. Copied verbatim (incl. .git)
into both the sidecar (engine workspace) and main (seed), so they share a sha."""
dest.mkdir(parents=True, exist_ok=True)
toplevel = subprocess.run(
["git", "-C", str(agent_repo), "rev-parse", "--show-toplevel"],
capture_output=True, text=True,
)
if toplevel.returncode == 0:
# Extract only the target subtree at HEAD (the repo may be a monorepo and
# agent_repo a subdirectory of it), stripping the leading path components.
repo_root = Path(toplevel.stdout.strip())
rel = agent_repo.relative_to(repo_root)
strip = len(rel.parts)
archive = subprocess.Popen(
["git", "-C", str(repo_root), "archive", "HEAD", str(rel)]
if strip else ["git", "-C", str(repo_root), "archive", "HEAD"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
subprocess.run(
["tar", "xf", "-", "--strip-components", str(strip)],
cwd=dest, stdin=archive.stdout, check=True,
)
finally:
# Let git see SIGPIPE if tar died, then reap it (no zombie).
if archive.stdout is not None:
archive.stdout.close()
archive_err = (archive.communicate()[1] or b"").decode(errors="replace")
Comment on lines +100 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 communicate() after stdout.close() raises ValueError — breaks git-repo builds

archive.stdout.close() (line 103) closes the underlying OS file descriptor and marks the Python file object as closed. The very next line calls archive.communicate(), which internally calls selector.register(self.stdout, EVENT_READ)self.stdout.fileno()ValueError: I/O operation on closed file (CPython's _fileobj_lookup re-raises ValueError for non-int file objects). This means _prepare_baseline_repo always raises ValueError for any git-tracked agent repo, so compile_task is entirely broken for the normal case and all of the built-fixture tests would fail.

The intended fix (get stderr for the error message + reap the child) should instead read archive.stderr directly and call archive.wait():

finally:
    if archive.stdout is not None:
        archive.stdout.close()
    archive_err = (archive.stderr.read() if archive.stderr else b"").decode(errors="replace")
    archive.wait()
Prompt To Fix With AI
This is a comment left during a code review.
Path: vero/src/vero/harbor/build/compiler.py
Line: 100-104

Comment:
**`communicate()` after `stdout.close()` raises `ValueError` — breaks git-repo builds**

`archive.stdout.close()` (line 103) closes the underlying OS file descriptor and marks the Python file object as closed. The very next line calls `archive.communicate()`, which internally calls `selector.register(self.stdout, EVENT_READ)``self.stdout.fileno()``ValueError: I/O operation on closed file` (CPython's `_fileobj_lookup` re-raises `ValueError` for non-int file objects). This means `_prepare_baseline_repo` always raises `ValueError` for any git-tracked agent repo, so `compile_task` is entirely broken for the normal case and all of the `built`-fixture tests would fail.

The intended fix (get stderr for the error message + reap the child) should instead read `archive.stderr` directly and call `archive.wait()`:
```python
finally:
    if archive.stdout is not None:
        archive.stdout.close()
    archive_err = (archive.stderr.read() if archive.stderr else b"").decode(errors="replace")
    archive.wait()
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

# A failed `git archive` can emit a truncated stream that `tar` still
# accepts with exit 0, baking a near-empty baseline. Fail loudly instead.
if archive.returncode != 0:
raise RuntimeError(
f"git archive failed (exit {archive.returncode}) for {repo_root}: "
f"{archive_err.strip()}"
)
else:
shutil.copytree(agent_repo, dest, dirs_exist_ok=True)

_rewrite_vero_source_path(dest / "pyproject.toml")

def git(*args: str) -> str:
return subprocess.run(
["git", "-c", "user.name=vero", "-c", "user.email=vero@localhost",
"-C", str(dest), *args],
capture_output=True, text=True, check=True,
).stdout.strip()

git("init", "-q")
git("add", "-A")
git("commit", "-q", "-m", "baseline")
return git("rev-parse", "HEAD")


def _register(dataset, vero_home: Path, tmp: Path) -> str:
"""Register a dataset (path/DatasetDict) into a baked VERO_HOME; return dataset_id."""
from vero.core.dataset.store import resolve_and_save_dataset

sessions = vero_home / "sessions"
datasets = vero_home / "datasets"
(sessions / SESSION_ID).mkdir(parents=True, exist_ok=True)
datasets.mkdir(parents=True, exist_ok=True)
if not isinstance(dataset, str): # a DatasetDict -> save_to_disk first
path = tmp / "ds"
dataset.save_to_disk(str(path))
dataset = str(path)
return resolve_and_save_dataset(dataset, sessions, datasets, SESSION_ID)

Comment thread
greptile-apps[bot] marked this conversation as resolved.

def _serve_config(config: BuildConfig, dataset_id: str | None, base_commit: str) -> dict:
harbor = None
if config.harbor is not None:
# Local inner task -> baked sidecar-only path; registry ref -> pass through.
harbor = {**config.harbor}
if config.inner_task:
harbor["task_source"] = INNER_TASK
targets = [
{
"task": config.task,
"dataset_id": dataset_id,
"split": t.split,
"reward_key": t.reward_key,
"sample_ids": t.sample_ids,
}
for t in config.targets
]
return {
"repo_path": AGENT_BASELINE,
"agent_repo_path": WORK_AGENT,
"session_id": SESSION_ID,
"dataset_id": dataset_id,
"split_accesses": [s.model_dump() for s in config.splits],
"budgets": [
{"split": b.split, "dataset_id": dataset_id, **b.model_dump(exclude={"split"}, exclude_none=True)}
for b in config.budgets
],
"task": config.task,
"task_project": config.task_project,
"task_module": config.task_module,
"harbor": harbor,
"reward_mode": config.reward_mode,
"selection_split": config.selection_split,
"targets": targets,
"base_commit": base_commit,
"submit_enabled": config.submit_enabled,
"agent_volume": AGENT_VOLUME,
"admin_volume": ADMIN_VOLUME,
"admin_token_path": TOKEN_PATH,
"timeout": config.timeout,
"sample_timeout": config.sample_timeout,
"max_concurrency": config.max_concurrency,
"host": "0.0.0.0",
"port": 8000,
}


def compile_task(
config: BuildConfig, out_dir: Path | str, *, vero_root: Path | None = None
) -> Path:
"""Compile ``config`` into a Harbor task directory at ``out_dir``."""
import json

from vero.core.constants import PACKAGE_DIR

vero_root = vero_root or PACKAGE_DIR
out = Path(out_dir)
if out.exists():
shutil.rmtree(out)
env_dir = out / "environment"
env_dir.mkdir(parents=True)

agent_repo = Path(config.agent_repo).resolve()

# 1. vero source (both images install from here)
_copy_vero_source(vero_root, env_dir / "vero")

# 2. baseline repo -> sidecar engine workspace + main seed (shared sha)
base_commit = _prepare_baseline_repo(agent_repo, env_dir / "agent-baseline")
shutil.copytree(env_dir / "agent-baseline", env_dir / "agent-seed")

# 3. dataset -> baked VERO_HOME. Mode A: input+label rows. Mode B: the
# {split: [task_names]} partition + the inner Harbor task baked sidecar-only.
import tempfile

vh = env_dir / "sidecar" / "vero_home"
# Stage the dataset in a scratch dir that is always cleaned up (datasets can be
# gigabytes; a leaked mkdtemp would accumulate across builds). _register copies
# the dataset into vh before the dir is torn down, so cleanup is safe.
with tempfile.TemporaryDirectory() as tmp_str:
tmp = Path(tmp_str)
if config.mode == "A":
if not config.dataset:
raise ValueError("Mode A requires a dataset.")
dataset_id = _register(config.dataset, vh, tmp)
else:
if not (config.partition and config.harbor):
raise ValueError("Mode B requires partition + harbor.")
if not (config.inner_task or config.harbor.get("task_source")):
raise ValueError("Mode B requires inner_task (local) or harbor.task_source (registry).")
from vero.harbor.dataset import build_harbor_dataset

dataset_id = _register(build_harbor_dataset(config.partition), vh, tmp)
if config.inner_task: # local benchmark -> bake sidecar-only
shutil.copytree(Path(config.inner_task).resolve(), env_dir / "sidecar" / "inner-task")

# 4. ServeConfig (compiler <-> serve contract)
(env_dir / "sidecar" / "serve.json").write_text(
json.dumps(_serve_config(config, dataset_id, base_commit), indent=2)
)

# 4b. Fail early if a declared secret is missing from the host env, so the
# operator finds out at build time rather than via a credential-less
# sidecar. The compose ${VAR:?} guard is the run-time backstop.
import os

if not os.environ.get("VERO_SKIP_SECRET_CHECK"):
missing = [s for s in config.secrets if not os.environ.get(s)]
if missing:
raise ValueError(
"Declared secrets missing from the host environment: "
f"{', '.join(missing)}. Set them, or set VERO_SKIP_SECRET_CHECK=1 "
"to defer to the run-time compose check."
)

# 5. render templates
jenv = Environment(
loader=FileSystemLoader(str(_TEMPLATES)),
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
ctx = dict(
name=config.name,
description=config.description,
mode=config.mode,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
timeout=config.timeout,
secrets=config.secrets,
read_only_paths=config.read_only_paths,
base_image_main=config.base_image_main,
base_image_sidecar=config.base_image_sidecar,
dataset_id=dataset_id,
selection_split=config.selection_split,
submit_enabled=config.submit_enabled,
eval_num_samples=None,
bake_inner_task=bool(config.inner_task),
)
_render(jenv, "task.toml.j2", out / "task.toml", **ctx)
_render(jenv, "instruction.md.j2", out / "instruction.md", **ctx)
_render(jenv, "docker-compose.yaml.j2", env_dir / "docker-compose.yaml", **ctx)
_render(jenv, "Dockerfile.main.j2", env_dir / "Dockerfile", **ctx)
_render(jenv, "Dockerfile.sidecar.j2", env_dir / "sidecar" / "Dockerfile", **ctx)
_render(jenv, "seed.sh.j2", env_dir / "main" / "seed.sh", **ctx)
_render(jenv, "test.sh.j2", out / "tests" / "test.sh", **ctx)
_render(jenv, "solve.sh.j2", out / "solution" / "solve.sh", **ctx)

for script in [out / "tests" / "test.sh", out / "solution" / "solve.sh",
env_dir / "main" / "seed.sh"]:
script.chmod(0o755)

logger.info("Compiled Harbor task -> %s (baseline %s)", out, base_commit[:12])
return out
Loading