758 lines
31 KiB
Python
758 lines
31 KiB
Python
"""
|
|
workspace_test.py - Comprehensive tests for workspace materialization and isolation.
|
|
|
|
Covers exact run/cell/repetition/attempt grammar, path containment, symlink/collision rejection,
|
|
asset mapping, prompt exclusion, checksum verification, session freshness, testbed provenance,
|
|
and cross-attempt isolation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from scripts.agent_benchmark.manifest import (
|
|
AssetMapping,
|
|
Fixture,
|
|
IopCell,
|
|
Manifest,
|
|
MatrixCell,
|
|
Timeout,
|
|
Viewport,
|
|
digest_manifest_and_resolved_inputs,
|
|
digest_workspace_inputs,
|
|
load_manifest,
|
|
)
|
|
from scripts.agent_benchmark.workspace import (
|
|
AttemptIdentity,
|
|
PreparedWorkspace,
|
|
TestbedError,
|
|
TestbedProvenance,
|
|
WorkspaceChecksumError,
|
|
WorkspaceError,
|
|
WorkspacePathError,
|
|
WorkspaceValidationError,
|
|
inspect_testbed_provenance,
|
|
prepare_workspace,
|
|
validate_attempt_identity,
|
|
)
|
|
|
|
|
|
class BaseWorkspaceTest(unittest.TestCase):
|
|
"""Base test class providing temporary Git repositories and testbed fixtures."""
|
|
|
|
def setUp(self) -> None:
|
|
self.tmp_dir_obj = tempfile.TemporaryDirectory()
|
|
self.tmp_dir = Path(self.tmp_dir_obj.name).resolve()
|
|
|
|
self.repo_root = self.tmp_dir / "repo"
|
|
self.repo_root.mkdir()
|
|
|
|
# Initialize git repo in repo_root
|
|
subprocess.run(["git", "init"], cwd=self.repo_root, capture_output=True, check=True)
|
|
subprocess.run(
|
|
["git", "config", "user.name", "Test"], cwd=self.repo_root, capture_output=True, check=True
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.email", "test@example.com"],
|
|
cwd=self.repo_root,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
|
|
# Initialize git repo in testbed (iop-s2)
|
|
self.testbed_dir = self.tmp_dir / "iop-s2"
|
|
self.testbed_dir.mkdir()
|
|
(self.testbed_dir / "README.md").write_text("testbed content", encoding="utf-8")
|
|
subprocess.run(["git", "init"], cwd=self.testbed_dir, capture_output=True, check=True)
|
|
subprocess.run(
|
|
["git", "config", "user.name", "Test"], cwd=self.testbed_dir, capture_output=True, check=True
|
|
)
|
|
subprocess.run(
|
|
["git", "config", "user.email", "test@example.com"],
|
|
cwd=self.testbed_dir,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
subprocess.run(["git", "add", "."], cwd=self.testbed_dir, capture_output=True, check=True)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "initial testbed commit"],
|
|
cwd=self.testbed_dir,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
|
|
# Create fixture files under repo_root
|
|
self.fixture_dir = self.repo_root / "scripts" / "fixtures" / "bench"
|
|
self.fixture_dir.mkdir(parents=True)
|
|
|
|
self.prompt_rel = "scripts/fixtures/bench/prompt.md"
|
|
self.prompt_file = self.repo_root / self.prompt_rel
|
|
self.prompt_content = b"# Test Prompt\nDo task.\n"
|
|
self.prompt_file.write_bytes(self.prompt_content)
|
|
|
|
self.ref_rel = "scripts/fixtures/bench/ref.txt"
|
|
self.ref_file = self.repo_root / self.ref_rel
|
|
self.ref_content = b"Reference data content\n"
|
|
self.ref_file.write_bytes(self.ref_content)
|
|
|
|
subprocess.run(["git", "add", "."], cwd=self.repo_root, capture_output=True, check=True)
|
|
subprocess.run(
|
|
["git", "commit", "-m", "add fixture files"],
|
|
cwd=self.repo_root,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
|
|
# Assets list (initially only ref.txt is an asset, prompt.md is separate prompt file)
|
|
self.assets = [
|
|
AssetMapping(source=self.ref_rel, workspace_path="data/ref.txt", content=self.ref_content)
|
|
]
|
|
self.fixture_checksum = digest_workspace_inputs(self.assets)
|
|
|
|
self.run_id = "run-20260809T161730Z-0123456789ab"
|
|
self.output_root_rel = "agent-test/runs/bench-01"
|
|
|
|
self.manifest_raw = {
|
|
"pipeline_version": "2",
|
|
"environment": "dev",
|
|
"testbed": "../iop-s2",
|
|
"repetitions": 2,
|
|
"session_policy": "fresh",
|
|
"setup_cache_policy": "isolated",
|
|
"timeout": {
|
|
"run_seconds": 300,
|
|
"idle_seconds": 30,
|
|
"quiet_seconds": 10,
|
|
"cleanup_grace_seconds": 5,
|
|
},
|
|
"viewports": [{"id": "desktop_1080", "width": 1920, "height": 1080}],
|
|
"rubric_version": "landing-quality-v1",
|
|
"evaluator": {"caller": "codex", "iop": {"request_model": "judge", "requested_effort": "high", "route_kind": "direct", "route_id": "judge", "expected_bindings": [{"stage": "request", "model": "judge", "effort": "high"}]}},
|
|
"output_root": self.output_root_rel,
|
|
"fixture": {
|
|
"version": "v1.0",
|
|
"prompt": self.prompt_rel,
|
|
"assets": [{"source": self.ref_rel, "workspace_path": "data/ref.txt"}],
|
|
"checksum": self.fixture_checksum,
|
|
},
|
|
"matrix": [
|
|
{
|
|
"id": "cell-1",
|
|
"caller": "claude",
|
|
"iop": {
|
|
"request_model": "claude-sonnet-4-20250514",
|
|
"requested_effort": "high",
|
|
"route_kind": "direct",
|
|
"route_id": "claude-direct",
|
|
"expected_bindings": [
|
|
{"stage": "request", "model": "claude-sonnet-4-20250514", "effort": "high"}
|
|
],
|
|
},
|
|
},
|
|
{
|
|
"id": "cell-2",
|
|
"caller": "agy",
|
|
"iop": {
|
|
"request_model": "gemini-2.0-flash",
|
|
"requested_effort": "high",
|
|
"route_kind": "direct",
|
|
"route_id": "agy-direct",
|
|
"expected_bindings": [
|
|
{"stage": "request", "model": "gemini-2.0-flash", "effort": "high"}
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
self.manifest_file = self.repo_root / "manifest.json"
|
|
self.manifest_file.write_text(json.dumps(self.manifest_raw, indent=2), encoding="utf-8")
|
|
self.manifest = load_manifest(self.manifest_file, repo_root=self.repo_root)
|
|
|
|
def tearDown(self) -> None:
|
|
self.tmp_dir_obj.cleanup()
|
|
|
|
def make_attempt_root(
|
|
self, cell_id: str = "cell-1", repetition: int = 1, attempt: int = 1
|
|
) -> tuple[AttemptIdentity, Path]:
|
|
identity = AttemptIdentity(
|
|
run_id=self.run_id, cell_id=cell_id, repetition=repetition, attempt=attempt
|
|
)
|
|
rep_segment = f"repetition-{repetition:04d}"
|
|
att_segment = f"attempt-{attempt:06d}"
|
|
attempt_root = (
|
|
self.repo_root
|
|
/ self.output_root_rel
|
|
/ self.run_id
|
|
/ "cells"
|
|
/ cell_id
|
|
/ rep_segment
|
|
/ att_segment
|
|
)
|
|
attempt_root.mkdir(parents=True, exist_ok=True)
|
|
return identity, attempt_root
|
|
|
|
|
|
class TestAttemptIdentityValidation(BaseWorkspaceTest):
|
|
"""Tests for AttemptIdentity format and boundary validation."""
|
|
|
|
def test_valid_identity(self) -> None:
|
|
identity = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="cell-1", repetition=1, attempt=1
|
|
)
|
|
validate_attempt_identity(identity, self.manifest)
|
|
|
|
def test_invalid_run_id_rejected(self) -> None:
|
|
bad_run_ids = [
|
|
"invalid_run_id",
|
|
"run-20260809161730Z-0123456789ab", # missing T
|
|
"run-20260809T161730Z-0123456789aG", # uppercase G
|
|
"run-20260809T161730Z-short",
|
|
]
|
|
for run_id in bad_run_ids:
|
|
identity = AttemptIdentity(run_id=run_id, cell_id="cell-1", repetition=1, attempt=1)
|
|
with self.assertRaises(WorkspaceValidationError):
|
|
validate_attempt_identity(identity, self.manifest)
|
|
|
|
def test_invalid_cell_id_rejected(self) -> None:
|
|
# Invalid format or missing from manifest
|
|
identity_bad_fmt = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="-invalid", repetition=1, attempt=1
|
|
)
|
|
with self.assertRaises(WorkspaceValidationError):
|
|
validate_attempt_identity(identity_bad_fmt, self.manifest)
|
|
|
|
identity_missing = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="cell-nonexistent", repetition=1, attempt=1
|
|
)
|
|
with self.assertRaises(WorkspaceValidationError):
|
|
validate_attempt_identity(identity_missing, self.manifest)
|
|
|
|
def test_invalid_repetition_rejected(self) -> None:
|
|
# Non-positive or exceeding manifest repetitions (2)
|
|
for rep in [0, -1, 3, True]:
|
|
identity = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="cell-1", repetition=rep, attempt=1
|
|
)
|
|
with self.assertRaises(WorkspaceValidationError):
|
|
validate_attempt_identity(identity, self.manifest)
|
|
|
|
def test_invalid_attempt_rejected(self) -> None:
|
|
for att in [0, -1, True]:
|
|
identity = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="cell-1", repetition=1, attempt=att
|
|
)
|
|
with self.assertRaises(WorkspaceValidationError):
|
|
validate_attempt_identity(identity, self.manifest)
|
|
|
|
|
|
class TestAttemptRootPathRules(BaseWorkspaceTest):
|
|
"""Tests path rules for attempt_root."""
|
|
|
|
def test_attempt_root_does_not_exist(self) -> None:
|
|
identity = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="cell-1", repetition=1, attempt=1
|
|
)
|
|
non_existent = (
|
|
self.repo_root
|
|
/ self.output_root_rel
|
|
/ self.run_id
|
|
/ "cells"
|
|
/ "cell-1"
|
|
/ "repetition-0001"
|
|
/ "attempt-000001"
|
|
)
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(
|
|
self.manifest, non_existent, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
def test_attempt_root_is_file(self) -> None:
|
|
identity, attempt_root = self.make_attempt_root()
|
|
attempt_root.rmdir()
|
|
attempt_root.write_text("file instead of dir", encoding="utf-8")
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
def test_attempt_root_is_symlink(self) -> None:
|
|
identity, attempt_root = self.make_attempt_root()
|
|
attempt_root.rmdir()
|
|
real_target = self.tmp_dir / "real_target"
|
|
real_target.mkdir()
|
|
attempt_root.symlink_to(real_target)
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
def test_attempt_root_parent_is_symlink(self) -> None:
|
|
identity = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="cell-1", repetition=1, attempt=1
|
|
)
|
|
parent_dir = (
|
|
self.repo_root
|
|
/ self.output_root_rel
|
|
/ self.run_id
|
|
/ "cells"
|
|
/ "cell-1"
|
|
/ "repetition-0001"
|
|
)
|
|
parent_dir.mkdir(parents=True, exist_ok=True)
|
|
real_parent = self.tmp_dir / "real_parent"
|
|
real_parent.mkdir()
|
|
attempt_root_real = real_parent / "attempt-000001"
|
|
attempt_root_real.mkdir()
|
|
|
|
symlink_att = parent_dir / "attempt-000001"
|
|
symlink_att.symlink_to(attempt_root_real)
|
|
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(
|
|
self.manifest, symlink_att, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
def test_attempt_root_not_empty(self) -> None:
|
|
identity, attempt_root = self.make_attempt_root()
|
|
(attempt_root / "existing.txt").write_text("existing", encoding="utf-8")
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
def test_attempt_root_canonical_path_mismatch(self) -> None:
|
|
identity = AttemptIdentity(
|
|
run_id=self.run_id, cell_id="cell-1", repetition=1, attempt=1
|
|
)
|
|
wrong_path = (
|
|
self.repo_root
|
|
/ self.output_root_rel
|
|
/ self.run_id
|
|
/ "cells"
|
|
/ "cell-1"
|
|
/ "repetition-0002" # mismatched repetition
|
|
/ "attempt-000001"
|
|
)
|
|
wrong_path.mkdir(parents=True, exist_ok=True)
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(self.manifest, wrong_path, identity, repo_root=self.repo_root)
|
|
|
|
def test_exclusive_child_collision(self) -> None:
|
|
identity, attempt_root = self.make_attempt_root()
|
|
(attempt_root / "workspace").mkdir()
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
|
|
class TestWorkspaceMaterialization(BaseWorkspaceTest):
|
|
"""Tests for workspace asset copying, prompt exclusion, checksums, and metadata."""
|
|
|
|
def test_successful_workspace_preparation(self) -> None:
|
|
identity, attempt_root = self.make_attempt_root()
|
|
prepared = prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
self.assertEqual(prepared.identity, identity)
|
|
self.assertEqual(prepared.workspace_checksum, self.manifest.fixture.checksum)
|
|
self.assertTrue(prepared.session_is_fresh)
|
|
self.assertEqual(prepared.setup_cache_policy, "isolated")
|
|
|
|
# Verify directories exist
|
|
ws_path = Path(prepared.workspace_dir)
|
|
session_path = Path(prepared.session_dir)
|
|
self.assertTrue(ws_path.is_dir())
|
|
self.assertTrue(session_path.is_dir())
|
|
|
|
# Verify session directory is empty
|
|
self.assertEqual(list(session_path.iterdir()), [])
|
|
|
|
# Verify asset materialization
|
|
asset_file = ws_path / "data" / "ref.txt"
|
|
self.assertTrue(asset_file.is_file())
|
|
self.assertEqual(asset_file.read_bytes(), self.ref_content)
|
|
|
|
# Verify prepared.json content
|
|
prep_json = Path(prepared.attempt_root) / "prepared.json"
|
|
self.assertTrue(prep_json.is_file())
|
|
data = json.loads(prep_json.read_text(encoding="utf-8"))
|
|
self.assertEqual(data["session_id"], prepared.session_id)
|
|
self.assertEqual(data["workspace_checksum"], prepared.workspace_checksum)
|
|
|
|
def test_prompt_exclusion_when_not_declared(self) -> None:
|
|
# Prompt file exists in fixture.prompt, but is not listed in fixture.assets
|
|
identity, attempt_root = self.make_attempt_root()
|
|
prepared = prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
ws_path = Path(prepared.workspace_dir)
|
|
|
|
# Prompt should not exist in workspace
|
|
prompt_ws = ws_path / self.prompt_rel
|
|
self.assertFalse(prompt_ws.exists())
|
|
|
|
def test_prompt_included_when_declared_as_asset(self) -> None:
|
|
# Update manifest to declare prompt.md as an asset too
|
|
new_assets = [
|
|
{"source": self.ref_rel, "workspace_path": "data/ref.txt"},
|
|
{"source": self.prompt_rel, "workspace_path": "prompt.md"},
|
|
]
|
|
asset_objs = [
|
|
AssetMapping(source=self.ref_rel, workspace_path="data/ref.txt", content=self.ref_content),
|
|
AssetMapping(source=self.prompt_rel, workspace_path="prompt.md", content=self.prompt_content),
|
|
]
|
|
checksum = digest_workspace_inputs(asset_objs)
|
|
self.manifest_raw["fixture"]["assets"] = new_assets
|
|
self.manifest_raw["fixture"]["checksum"] = checksum
|
|
|
|
self.manifest_file.write_text(json.dumps(self.manifest_raw, indent=2), encoding="utf-8")
|
|
manifest = load_manifest(self.manifest_file, repo_root=self.repo_root)
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
prepared = prepare_workspace(manifest, attempt_root, identity, repo_root=self.repo_root)
|
|
ws_path = Path(prepared.workspace_dir)
|
|
|
|
prompt_ws = ws_path / "prompt.md"
|
|
self.assertTrue(prompt_ws.is_file())
|
|
self.assertEqual(prompt_ws.read_bytes(), self.prompt_content)
|
|
|
|
def test_fixture_checksum_mismatch_rejected(self) -> None:
|
|
# Corrupt declared checksum in manifest
|
|
self.manifest_raw["fixture"]["checksum"] = "sha256:" + "0" * 64
|
|
self.manifest_file.write_text(json.dumps(self.manifest_raw, indent=2), encoding="utf-8")
|
|
|
|
# Manually create manifest with corrupt checksum to bypass load_manifest validation
|
|
dummy_manifest = Manifest(
|
|
pipeline_version=self.manifest.pipeline_version,
|
|
environment=self.manifest.environment,
|
|
testbed=self.manifest.testbed,
|
|
repetitions=self.manifest.repetitions,
|
|
session_policy=self.manifest.session_policy,
|
|
setup_cache_policy=self.manifest.setup_cache_policy,
|
|
timeout=self.manifest.timeout,
|
|
viewports=self.manifest.viewports,
|
|
rubric_version=self.manifest.rubric_version,
|
|
evaluator=self.manifest.evaluator,
|
|
output_root=self.manifest.output_root,
|
|
fixture=Fixture(
|
|
version=self.manifest.fixture.version,
|
|
prompt=self.manifest.fixture.prompt,
|
|
assets=self.manifest.fixture.assets,
|
|
checksum="sha256:" + "0" * 64,
|
|
prompt_content=self.manifest.fixture.prompt_content,
|
|
),
|
|
matrix=self.manifest.matrix,
|
|
digest=self.manifest.digest,
|
|
)
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
with self.assertRaises(WorkspaceChecksumError):
|
|
prepare_workspace(dummy_manifest, attempt_root, identity, repo_root=self.repo_root)
|
|
|
|
def test_symlink_asset_source_rejected(self) -> None:
|
|
symlink_source = self.repo_root / "scripts" / "fixtures" / "bench" / "symlink.txt"
|
|
symlink_source.symlink_to(self.ref_file)
|
|
|
|
symlink_rel = "scripts/fixtures/bench/symlink.txt"
|
|
self.manifest_raw["fixture"]["assets"] = [
|
|
{"source": symlink_rel, "workspace_path": "data/symlink.txt"}
|
|
]
|
|
asset_objs = [
|
|
AssetMapping(source=symlink_rel, workspace_path="data/symlink.txt", content=self.ref_content)
|
|
]
|
|
self.manifest_raw["fixture"]["checksum"] = digest_workspace_inputs(asset_objs)
|
|
self.manifest_file.write_text(json.dumps(self.manifest_raw, indent=2), encoding="utf-8")
|
|
|
|
# Create manifest directly to bypass load_manifest symlink check
|
|
dummy_manifest = Manifest(
|
|
pipeline_version=self.manifest.pipeline_version,
|
|
environment=self.manifest.environment,
|
|
testbed=self.manifest.testbed,
|
|
repetitions=self.manifest.repetitions,
|
|
session_policy=self.manifest.session_policy,
|
|
setup_cache_policy=self.manifest.setup_cache_policy,
|
|
timeout=self.manifest.timeout,
|
|
viewports=self.manifest.viewports,
|
|
rubric_version=self.manifest.rubric_version,
|
|
evaluator=self.manifest.evaluator,
|
|
output_root=self.manifest.output_root,
|
|
fixture=Fixture(
|
|
version=self.manifest.fixture.version,
|
|
prompt=self.manifest.fixture.prompt,
|
|
assets=(AssetMapping(source=symlink_rel, workspace_path="data/symlink.txt", content=self.ref_content),),
|
|
checksum=self.manifest_raw["fixture"]["checksum"],
|
|
prompt_content=self.manifest.fixture.prompt_content,
|
|
),
|
|
matrix=self.manifest.matrix,
|
|
digest=self.manifest.digest,
|
|
)
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(dummy_manifest, attempt_root, identity, repo_root=self.repo_root)
|
|
|
|
def test_escaping_workspace_path_rejected(self) -> None:
|
|
bad_assets = [
|
|
{"source": self.ref_rel, "workspace_path": "../data/ref.txt"},
|
|
]
|
|
self.manifest_raw["fixture"]["assets"] = bad_assets
|
|
self.manifest_file.write_text(json.dumps(self.manifest_raw, indent=2), encoding="utf-8")
|
|
|
|
with self.assertRaises(Exception):
|
|
load_manifest(self.manifest_file, repo_root=self.repo_root)
|
|
|
|
def test_source_drift_failure_leaves_attempt_root_empty_and_retryable(self) -> None:
|
|
"""R1: Mutate a fixture source after manifest load, prove rollback and retry."""
|
|
identity, attempt_root = self.make_attempt_root()
|
|
|
|
# Capture original ref.txt content
|
|
original_ref_content = self.ref_file.read_bytes()
|
|
|
|
# Mutate the source file after manifest load (simulates source drift)
|
|
self.ref_file.write_bytes(b"corrupted reference data\n")
|
|
|
|
with self.assertRaises(WorkspaceChecksumError):
|
|
prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
# Assert the attempt root is empty (rollback succeeded)
|
|
self.assertEqual(list(attempt_root.iterdir()), [])
|
|
|
|
# Restore the original source
|
|
self.ref_file.write_bytes(original_ref_content)
|
|
|
|
# Prove the same attempt root can succeed on retry
|
|
# (need to recreate it since rollback removed it)
|
|
identity2, attempt_root2 = self.make_attempt_root()
|
|
prepared = prepare_workspace(
|
|
self.manifest, attempt_root2, identity2, repo_root=self.repo_root
|
|
)
|
|
self.assertEqual(prepared.identity, identity2)
|
|
self.assertTrue(Path(prepared.workspace_dir).is_dir())
|
|
self.assertTrue(Path(prepared.session_dir).is_dir())
|
|
|
|
def test_postflight_failure_leaves_attempt_root_empty(self) -> None:
|
|
"""R1: Deterministic mocked postflight failure proves rollback of all owned entries."""
|
|
import unittest.mock
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
|
|
# Mock inspect_testbed_provenance to succeed on first call (preflight) but fail on second (postflight)
|
|
original_inspect = inspect_testbed_provenance
|
|
call_count = {"n": 0}
|
|
|
|
def mock_inspect(path):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return original_inspect(path) # preflight succeeds
|
|
# postflight raises TestbedError
|
|
from scripts.agent_benchmark.workspace import TestbedError as TE
|
|
raise TE("mocked postflight: testbed modified")
|
|
|
|
with unittest.mock.patch(
|
|
"scripts.agent_benchmark.workspace.inspect_testbed_provenance",
|
|
side_effect=mock_inspect,
|
|
):
|
|
with self.assertRaises(TestbedError):
|
|
prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
# Assert the attempt root is empty (rollback succeeded)
|
|
self.assertEqual(list(attempt_root.iterdir()), [])
|
|
|
|
# Assert prepared.json does not exist
|
|
prepared_json = attempt_root / "prepared.json"
|
|
self.assertFalse(prepared_json.exists())
|
|
|
|
def test_ancestor_destination_collision_rejected_before_mutation(self) -> None:
|
|
"""R1: Asset destinations with ancestor/file conflict are rejected before mutation."""
|
|
new_assets = [
|
|
{"source": self.ref_rel, "workspace_path": "data"},
|
|
{"source": self.prompt_rel, "workspace_path": "data/prompt.md"},
|
|
]
|
|
asset_objs = [
|
|
AssetMapping(source=self.ref_rel, workspace_path="data", content=self.ref_content),
|
|
AssetMapping(source=self.prompt_rel, workspace_path="data/prompt.md", content=self.prompt_content),
|
|
]
|
|
checksum = digest_workspace_inputs(asset_objs)
|
|
self.manifest_raw["fixture"]["assets"] = new_assets
|
|
self.manifest_raw["fixture"]["checksum"] = checksum
|
|
self.manifest_file.write_text(json.dumps(self.manifest_raw, indent=2), encoding="utf-8")
|
|
manifest = load_manifest(self.manifest_file, repo_root=self.repo_root)
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(manifest, attempt_root, identity, repo_root=self.repo_root)
|
|
|
|
# Assert attempt_root remains completely empty (no staging, workspace, or session created)
|
|
self.assertEqual(list(attempt_root.iterdir()), [])
|
|
|
|
def test_concurrent_collision_preserves_unrelated_entries(self) -> None:
|
|
"""R1: Concurrent collision content not created by this preparation is preserved on rollback."""
|
|
import unittest.mock
|
|
import scripts.agent_benchmark.workspace
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
|
|
def mock_verify_with_collision(staging, manifest):
|
|
# Simulate a caller/external collision creating workspace/caller-owned.txt after validation
|
|
caller_ws = attempt_root / "workspace"
|
|
caller_ws.mkdir(exist_ok=True)
|
|
sentinel = caller_ws / "caller-owned.txt"
|
|
sentinel.write_text("caller owned content", encoding="utf-8")
|
|
raise TestbedError("mocked failure during preparation")
|
|
|
|
with unittest.mock.patch(
|
|
"scripts.agent_benchmark.workspace._verify_staged_preparation",
|
|
side_effect=mock_verify_with_collision,
|
|
):
|
|
with self.assertRaises(TestbedError):
|
|
prepare_workspace(self.manifest, attempt_root, identity, repo_root=self.repo_root)
|
|
|
|
# Assert caller-owned collision file was preserved on rollback
|
|
sentinel = attempt_root / "workspace" / "caller-owned.txt"
|
|
self.assertTrue(sentinel.is_file())
|
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "caller owned content")
|
|
|
|
def test_empty_publication_collision_preserves_unrelated_directory(self) -> None:
|
|
"""R1: Empty concurrent collision directory created before final publication is preserved on rollback."""
|
|
import unittest.mock
|
|
import scripts.agent_benchmark.workspace
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
|
|
empty_inode: int | None = None
|
|
orig_publish = scripts.agent_benchmark.workspace._publish_owned_directory
|
|
|
|
def mock_publish_with_empty_collision(staging_dir, final_dir, owned, collision_message):
|
|
nonlocal empty_inode
|
|
if final_dir.name == "workspace":
|
|
final_dir.mkdir(exist_ok=False)
|
|
empty_inode = final_dir.stat().st_ino
|
|
return orig_publish(staging_dir, final_dir, owned, collision_message)
|
|
|
|
with unittest.mock.patch(
|
|
"scripts.agent_benchmark.workspace._publish_owned_directory",
|
|
side_effect=mock_publish_with_empty_collision,
|
|
):
|
|
with self.assertRaises(WorkspacePathError):
|
|
prepare_workspace(self.manifest, attempt_root, identity, repo_root=self.repo_root)
|
|
|
|
caller_ws = attempt_root / "workspace"
|
|
self.assertTrue(caller_ws.is_dir())
|
|
self.assertIsNotNone(empty_inode)
|
|
self.assertEqual(caller_ws.stat().st_ino, empty_inode)
|
|
self.assertEqual(list(caller_ws.iterdir()), [])
|
|
self.assertFalse((attempt_root / "session").exists())
|
|
self.assertFalse((attempt_root / "prepared.json").exists())
|
|
self.assertEqual(list(attempt_root.iterdir()), [caller_ws])
|
|
|
|
|
|
class TestTestbedProvenanceAndNonMutation(BaseWorkspaceTest):
|
|
"""Tests for runtime testbed provenance checking and non-mutation."""
|
|
|
|
def test_clean_testbed_provenance(self) -> None:
|
|
prov = inspect_testbed_provenance(self.testbed_dir)
|
|
self.assertTrue(prov.clean)
|
|
self.assertTrue(prov.status_digest.startswith("sha256:"))
|
|
self.assertTrue(len(prov.head) > 0)
|
|
|
|
def test_dirty_testbed_rejected(self) -> None:
|
|
# Create an uncommitted file in testbed_dir
|
|
(self.testbed_dir / "dirty.txt").write_text("dirty", encoding="utf-8")
|
|
|
|
with self.assertRaises(TestbedError):
|
|
inspect_testbed_provenance(self.testbed_dir)
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
with self.assertRaises(TestbedError):
|
|
prepare_workspace(self.manifest, attempt_root, identity, repo_root=self.repo_root)
|
|
|
|
def test_testbed_unaffected_by_preparation(self) -> None:
|
|
prov_before = inspect_testbed_provenance(self.testbed_dir)
|
|
|
|
identity, attempt_root = self.make_attempt_root()
|
|
prepared = prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
|
|
prov_after = inspect_testbed_provenance(self.testbed_dir)
|
|
self.assertEqual(prov_before, prov_after)
|
|
|
|
# Assert no file from testbed_dir was copied into workspace_dir
|
|
ws_files = list(Path(prepared.workspace_dir).rglob("*"))
|
|
ws_rel_paths = {f.name for f in ws_files}
|
|
self.assertNotIn("README.md", ws_rel_paths) # testbed README.md is not in workspace
|
|
|
|
|
|
class TestCrossAttemptIsolation(BaseWorkspaceTest):
|
|
"""API-2: Prove cross-attempt isolation and source integrity."""
|
|
|
|
def test_cross_attempt_isolation_and_source_integrity(self) -> None:
|
|
# Prepare 4 attempt roots (2 cells x 2 repetitions)
|
|
testbed_before = inspect_testbed_provenance(self.testbed_dir)
|
|
|
|
attempts_config = [
|
|
("cell-1", 1, 1),
|
|
("cell-1", 2, 1),
|
|
("cell-2", 1, 1),
|
|
("cell-2", 2, 1),
|
|
]
|
|
|
|
prepared_list: list[PreparedWorkspace] = []
|
|
for cell_id, rep, att in attempts_config:
|
|
identity, attempt_root = self.make_attempt_root(
|
|
cell_id=cell_id, repetition=rep, attempt=att
|
|
)
|
|
prep = prepare_workspace(
|
|
self.manifest, attempt_root, identity, repo_root=self.repo_root
|
|
)
|
|
prepared_list.append(prep)
|
|
|
|
# 1. Assert four distinct workspace/session identities
|
|
session_ids = {p.session_id for p in prepared_list}
|
|
self.assertEqual(len(session_ids), 4)
|
|
|
|
attempt_roots = {p.attempt_root for p in prepared_list}
|
|
self.assertEqual(len(attempt_roots), 4)
|
|
|
|
# 2. Assert identical initial workspace digests matching manifest.fixture.checksum
|
|
workspace_checksums = {p.workspace_checksum for p in prepared_list}
|
|
self.assertEqual(workspace_checksums, {self.manifest.fixture.checksum})
|
|
|
|
# 3. Mutate one session (session #1) and prove peer sessions remain empty
|
|
session1_path = Path(prepared_list[0].session_dir)
|
|
sentinel = session1_path / "history-sentinel"
|
|
sentinel.write_text("owned", encoding="utf-8")
|
|
self.assertTrue(sentinel.is_file())
|
|
|
|
for peer in prepared_list[1:]:
|
|
self.assertEqual(list(Path(peer.session_dir).iterdir()), [])
|
|
|
|
# 4. Capture testbed branch/HEAD/status digest before and after and assert equality
|
|
testbed_after = inspect_testbed_provenance(self.testbed_dir)
|
|
self.assertEqual(testbed_before, testbed_after)
|
|
|
|
# 5. Assert no path under testbed appears beneath any workspace
|
|
testbed_files = {f.name for f in self.testbed_dir.rglob("*") if f.is_file()}
|
|
for prep in prepared_list:
|
|
ws_files = {f.name for f in Path(prep.workspace_dir).rglob("*") if f.is_file()}
|
|
# Intersection of testbed files and workspace files should be empty
|
|
# (testbed contains README.md; workspace contains data/ref.txt)
|
|
self.assertEqual(testbed_files.intersection(ws_files), set())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|