-
Notifications
You must be signed in to change notification settings - Fork 662
feat(v1): grade in an isolated box, with Harbor-native artifacts #2144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
c3eab90
feat(v1): grade in an isolated box, with Harbor-native artifacts
rasdani a657bd4
refactor(v1): slim artifacts.py, and narrow the symlink rule
rasdani a8c7896
refactor(v1): drop archive vetting, accept the tampered-tar risk
rasdani ab941d8
refactor(v1): rename capture_patch's `publish` to `write_path`
rasdani 6a18ba9
refactor(v1): drop CollectedArtifact, return a dict of root -> archive
rasdani af508ac
refactor(v1): move fire-and-forget teardown onto Runtime.stop_nowait
rasdani 85a3af6
refactor(v1): drop stop_nowait, tear the solver box down normally
rasdani 91fa03b
refactor(v1): drop typo guards on artifact sources
rasdani e787bc9
fix(v1): let an unpinned judge runtime inherit the solver's
rasdani cce0921
Merge origin/main into feat/isolated-grading-artifacts
rasdani 4492b14
fix(v1): resolve relative artifact sources against the runtime workdir
rasdani 8355eaf
refactor(v1): inline the workdir resolution
rasdani 9fe09e0
fix(v1): attribute patch-capture failures, and skip grading on infra …
rasdani 245f806
docs(v1): say why the failed-solver path returns instead of raising
rasdani a6b82e3
fix(v1): keep `shared` as the agentic-judge default
rasdani e7ecfaf
fix(v1): capture the agent's edits only, not the image's untracked files
rasdani 7236d4b
feat(v1): `snapshot_untracked`, the exact form of the same exclusion
rasdani 5564d3f
refactor(v1): drop the mtime cutoff, keep only the recorded set
rasdani 9ac6daf
refactor(v1): address isolated grading review feedback (#2160)
rasdani 615efbb
Merge origin/main into feat/isolated-grading-artifacts
rasdani 960d45b
refactor(v1): one mode-neutral workspace note
rasdani 132d4b3
Apply suggestion from @macroscopeapp[bot]
rasdani 4228c44
fix(v1): address isolated grading review feedback
hallerite 3f8917c
Merge remote-tracking branch 'origin/main' into codex/pr-2144-takeover
hallerite fef21cd
fix(v1): validate isolated judge runtime early
hallerite 96f2b59
fix(v1): disambiguate judge text sources
hallerite 858ac72
test(v1): give coding harnesses token headroom
hallerite File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| """Artifact collection and restoration across runtimes.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import shlex | ||
| import uuid | ||
| from pathlib import PurePosixPath | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from pydantic import Field | ||
|
|
||
| from verifiers.v1.types import StrictBaseModel | ||
|
|
||
| if TYPE_CHECKING: | ||
| from verifiers.v1.runtimes import Runtime | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| ARTIFACTS_DIR = "/logs/artifacts" | ||
| """Implicit artifact directory; tasks that write here need no declaration.""" | ||
|
|
||
| MAX_ARTIFACT_BYTES = 32 * 1024 * 1024 | ||
| """Ceiling per collection. Sized for a delta, not a tree: the grading box boots from the | ||
| agent's image, so the repo is already there and only its output has to travel.""" | ||
|
|
||
|
|
||
| class Artifact(StrictBaseModel): | ||
| """One path to restore at the same location in another runtime.""" | ||
|
|
||
| source: str | ||
| exclude: list[str] = Field(default_factory=list) | ||
| """`tar --exclude` patterns, applied when `source` is a directory.""" | ||
|
|
||
|
|
||
| async def collect( | ||
| runtime: Runtime, artifacts: list[Artifact] | None = None | ||
| ) -> dict[str, bytes]: | ||
| """Tar the convention dir and every declared path out of `runtime`. | ||
|
|
||
| Keyed by source path; the values are tar archives. Insertion order is the order | ||
| they were declared, and a path cannot be collected twice. | ||
|
|
||
| A declared source that is missing raises: it was declared because grading needs it, | ||
| and grading a partial state scores the rollout wrong rather than failing it. The | ||
| implicit convention sweep is exempt — most tasks never write there. | ||
|
|
||
| Each source is archived separately so its exclude patterns stay local. | ||
| """ | ||
| # Resolve relative sources against the runtime workdir. Joining also normalises | ||
| # `/work/` to `/work`, so one tree cannot key two entries (the source is both the | ||
| # dict key and `restore`'s rm -rf target). | ||
| workdir = PurePosixPath(getattr(runtime.config, "workdir", "") or "/") | ||
| declared = [ | ||
| a.model_copy(update={"source": str(workdir / a.source)}) | ||
| for a in artifacts or [] | ||
| ] | ||
| convention = PurePosixPath(ARTIFACTS_DIR) | ||
| sweep = not any( | ||
| (p := PurePosixPath(a.source)) == convention | ||
| or p.is_relative_to(convention) | ||
| or convention.is_relative_to(p) | ||
| for a in declared | ||
| ) | ||
| entries = ([Artifact(source=ARTIFACTS_DIR)] if sweep else []) + declared | ||
|
|
||
| collected: dict[str, bytes] = {} | ||
| budget = MAX_ARTIFACT_BYTES | ||
| for artifact in entries: | ||
| source = artifact.source | ||
| if (await runtime.run(["test", "-e", source], {})).exit_code != 0: | ||
| if sweep and source == ARTIFACTS_DIR: | ||
| continue | ||
| raise RuntimeError( | ||
| f"declared artifact {source!r} does not exist in the runtime" | ||
| ) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| archive = await _tar_out(runtime, artifact, budget) | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| budget -= len(archive) | ||
| collected[source] = archive | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| logger.debug("collected artifact roots: %s", list(collected)) | ||
| return collected | ||
|
|
||
|
|
||
| async def restore(runtime: Runtime, collected: dict[str, bytes]) -> None: | ||
| """Extract `collected` in `runtime` at the original absolute paths.""" | ||
| if not collected: | ||
| return | ||
| # Restoring into the subprocess runtime would extract absolute paths onto the | ||
| # developer's filesystem, so refuse it before any archive reaches the host. | ||
| if getattr(runtime.config, "type", None) == "subprocess": | ||
| raise RuntimeError( | ||
| "refusing to restore artifacts into the subprocess runtime: extraction " | ||
| "writes to absolute paths on the host. Grade in a container." | ||
| ) | ||
| # Clear every root up front, not per entry: a later nested root would otherwise | ||
| # delete content an earlier one just restored. Clearing also drops any file or | ||
| # symlink the image left at the target. | ||
| roots = " ".join(shlex.quote(root) for root in collected) | ||
| await _run(runtime, f"rm -rf -- {roots}", "clear artifact roots") | ||
| for root, archive in collected.items(): | ||
| path = f"/tmp/vf-artifact-{uuid.uuid4().hex}.tar" | ||
| await runtime.write(path, archive) | ||
| await _run( | ||
| runtime, | ||
| f"tar -xf {shlex.quote(path)} -C / && rm -f {shlex.quote(path)}", | ||
| f"restore artifact {root!r}", | ||
| ) | ||
|
|
||
|
|
||
| async def _tar_out(runtime: Runtime, artifact: Artifact, budget: int) -> bytes: | ||
| path = f"/tmp/vf-artifact-{uuid.uuid4().hex}.tar" | ||
| excludes = " ".join(f"--exclude={shlex.quote(p)}" for p in artifact.exclude) | ||
| try: | ||
| await _run( | ||
| runtime, | ||
| f"tar -cf {shlex.quote(path)} -C / {excludes} -- " | ||
| f"{shlex.quote(artifact.source.lstrip('/'))}", | ||
| f"collect artifact {artifact.source!r}", | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| ) | ||
| # Size it in the box: an oversized collection is refused before it reaches host | ||
| # memory, not after. | ||
| sized = await runtime.run(["sh", "-c", f"wc -c < {shlex.quote(path)}"], {}) | ||
| if (raw := sized.stdout.strip()).isdigit() and int(raw) > budget: | ||
| raise RuntimeError( | ||
| f"artifact {artifact.source!r} takes the collection over the " | ||
| f"{MAX_ARTIFACT_BYTES} byte limit. The grading box boots from the " | ||
| "agent's image, so only the delta needs to travel — narrow the source " | ||
| "or add `exclude` patterns." | ||
| ) | ||
| return await runtime.read(path) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| finally: | ||
| # Best-effort: the box is about to be destroyed and the name is unique per call. | ||
| try: | ||
| await runtime.run(["rm", "-f", path], {}) | ||
| except Exception: | ||
| logger.debug("failed to remove %s", path, exc_info=True) | ||
|
|
||
|
|
||
| async def _run(runtime: Runtime, command: str, action: str) -> None: | ||
| result = await runtime.run(["sh", "-c", command], {}) | ||
| if result.exit_code: | ||
| detail = (result.stderr or result.stdout).strip()[-500:] | ||
| raise RuntimeError(f"failed to {action}: {detail}") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.