Skip to content

Commit 5f674e3

Browse files
committed
fix(forge): scope fresh campaign Docker evidence
1 parent 39005f6 commit 5f674e3

9 files changed

Lines changed: 312 additions & 94 deletions

File tree

src/swe_forge/execution/sandbox.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
import re
1313
import uuid
14+
from collections.abc import Iterator
15+
from contextlib import contextmanager
16+
from contextvars import ContextVar
1417
from dataclasses import dataclass, field
1518
from datetime import datetime, timezone
1619
from logging import getLogger
@@ -30,6 +33,31 @@
3033

3134
logger = getLogger(__name__)
3235

36+
_DOCKER_NAME_PREFIX: ContextVar[str] = ContextVar(
37+
"swe_forge_docker_name_prefix", default=""
38+
)
39+
40+
41+
def scoped_docker_name(name: str) -> str:
42+
"""Prefix a temporary Docker resource with the active run owner."""
43+
prefix = _DOCKER_NAME_PREFIX.get()
44+
if not prefix or name == prefix or name.startswith(f"{prefix}-"):
45+
return name
46+
return f"{prefix}-{name}"
47+
48+
49+
@contextmanager
50+
def docker_name_prefix(prefix: str) -> Iterator[None]:
51+
"""Scope temporary Docker names to one evidence-owning campaign run."""
52+
normalized = prefix.strip().rstrip("-")
53+
if not normalized:
54+
raise ValueError("Docker name prefix must not be empty")
55+
token = _DOCKER_NAME_PREFIX.set(normalized)
56+
try:
57+
yield
58+
finally:
59+
_DOCKER_NAME_PREFIX.reset(token)
60+
3361

3462
@dataclass
3563
class SandboxConfig:
@@ -114,7 +142,9 @@ def __init__(
114142

115143
# Generate unique container name to avoid collisions
116144
unique_suffix = uuid.uuid4().hex[:8]
117-
self._container_name = f"{self._config.name}-{unique_suffix}"
145+
self._container_name = (
146+
f"{scoped_docker_name(self._config.name)}-{unique_suffix}"
147+
)
118148

119149
self._state = SandboxState()
120150
self._manager: ContainerManager | None = None
@@ -144,7 +174,7 @@ def from_spec(
144174
pids_limit=spec.pids_limit,
145175
)
146176
sandbox = cls(client, config)
147-
sandbox._container_name = spec.name
177+
sandbox._container_name = scoped_docker_name(spec.name)
148178
return sandbox
149179

150180
async def __aenter__(self) -> "DockerSandbox":

src/swe_forge/forge/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
run_pilot,
9191
)
9292
from swe_forge.forge.fresh_campaign import (
93+
DockerEvidenceCollector,
9394
FreshCampaignConfig,
9495
FreshCampaignError,
9596
run_fresh_campaign,
@@ -3817,6 +3818,7 @@ def gold_prover(request: ExportRequest) -> bool:
38173818
config,
38183819
processor=processor,
38193820
gold_prover=gold_prover,
3821+
docker_evidence=DockerEvidenceCollector(config.run_id),
38203822
)
38213823
)
38223824
except (FreshCampaignError, PilotError, MissingCredentialsError) as exc:

src/swe_forge/forge/envbuild/builder.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
NoAdapterFoundError,
3939
build_default_registry,
4040
)
41+
from swe_forge.execution.sandbox import scoped_docker_name
4142
from swe_forge.forge.models import EnvImage, RepoSpec
4243

4344
# Failure kinds (recorded on a rejected build so install/build vs test failures
@@ -479,7 +480,9 @@ def _checkout(self, spec: RepoSpec, dest: Path) -> _Checkout:
479480
return _Checkout(ok=True, head=head)
480481

481482
def _unique_name(self, role: str) -> str:
482-
return f"{self._namespace}-{role}-{self._run_id}-{uuid.uuid4().hex[:8]}"
483+
return scoped_docker_name(
484+
f"{self._namespace}-{role}-{self._run_id}-{uuid.uuid4().hex[:8]}"
485+
)
483486

484487
def _image_tag(self, repo_id: str, commit: str) -> str:
485488
name = re.sub(r"[^a-z0-9._-]+", "_", repo_id.strip().lower()).strip("._-")

src/swe_forge/forge/fresh_campaign.py

Lines changed: 129 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from pathlib import Path
2323
from typing import Protocol
2424

25+
from swe_forge.execution.sandbox import docker_name_prefix
2526
from swe_forge.forge.calibrate.filter import DEFAULT_BAND_FILTER, BandFilterConfig
2627
from swe_forge.forge.export import (
2728
ExportRequest,
@@ -511,13 +512,28 @@ class DockerResource:
511512
status: str
512513
started_at: str
513514

515+
def to_dict(self) -> dict[str, str]:
516+
return {
517+
"name": self.name,
518+
"identity": self.identity,
519+
"status": self.status,
520+
"started_at": self.started_at,
521+
}
522+
514523

515524
@dataclass(frozen=True)
516525
class DockerSnapshot:
517526
protected: tuple[DockerResource, ...] = ()
518527
mission_owned: tuple[DockerResource, ...] = ()
519528
dangling_images: tuple[str, ...] = ()
520529

530+
def to_dict(self) -> dict[str, object]:
531+
return {
532+
"protected": [resource.to_dict() for resource in self.protected],
533+
"mission_owned": [resource.to_dict() for resource in self.mission_owned],
534+
"dangling_images": list(self.dangling_images),
535+
}
536+
521537

522538
def _parse_docker_rows(raw: str) -> tuple[DockerResource, ...]:
523539
rows: list[DockerResource] = []
@@ -586,7 +602,9 @@ def snapshot(self) -> DockerSnapshot:
586602
)
587603
)
588604
owned = tuple(
589-
resource for resource in resources if resource.name.startswith(prefix)
605+
resource
606+
for resource in resources
607+
if resource.name == prefix or resource.name.startswith(f"{prefix}-")
590608
)
591609
images = tuple(
592610
line.strip()
@@ -626,7 +644,7 @@ def compare(before: DockerSnapshot, after: DockerSnapshot) -> dict[str, object]:
626644
)
627645
before_owned = {item.name for item in before.mission_owned}
628646
after_owned = {item.name for item in after.mission_owned}
629-
if after_owned - before_owned:
647+
if after_owned:
630648
raise FreshCampaignError(
631649
"fresh campaign left mission-owned Docker resources"
632650
)
@@ -841,7 +859,13 @@ async def run_fresh_campaign(
841859
)
842860
recorder = StageEvidence(config.evidence_path)
843861
before = docker_evidence.snapshot() if docker_evidence else None
862+
if before is not None and before.mission_owned:
863+
raise FreshCampaignError(
864+
"fresh campaign run prefix already owns Docker resources"
865+
)
844866
result = FreshCampaignResult("running", "", config.run_id)
867+
if before is not None:
868+
result.docker_evidence["before"] = before.to_dict()
845869
current = load_published_generation(config.out_dir)
846870
expected_generation = current.generation_id if current is not None else ""
847871
result.publication_expected_generation = expected_generation
@@ -851,99 +875,111 @@ async def run_fresh_campaign(
851875
pending_request: ExportRequest | None = None
852876
pending_identity = ""
853877
try:
854-
recorder.mark(0)
855-
recorder.mark(1)
856-
recorder.mark(2)
857-
recorder.mark(3)
858-
recorder.mark(4)
859-
for plan in fresh_boltons_plans(config.plans, authority):
860-
if ledger.remaining_cap < config.worst_case_cost_usd:
861-
result.status = "cap_exhausted"
862-
result.reason = "no next request fits within the remaining $50 cap"
863-
break
864-
claim = authority.claim(
865-
plan,
866-
reason="fresh unprocessed boltons hard-rung candidate",
878+
if docker_evidence is not None:
879+
result.docker_evidence["induced_failure"] = (
880+
docker_evidence.induced_failure_teardown(("sh", "-c", "exit 97"))
867881
)
868-
identity = claim.identity
869-
with ledger.call_context(
870-
candidate_identity=identity, stage="fresh-campaign.stage-1-4"
871-
):
872-
artifacts = await processor.process(
873-
plan, config.out_dir / f".{identity}"
874-
)
875-
if ledger.unresolved:
876-
raise FreshCampaignError(
877-
"provider billing is unresolved; publication is forbidden"
878-
)
879-
request = _keep_export_request(artifacts)
880-
disposition: dict[str, object] = {
881-
"identity": identity,
882-
"fresh_reason": claim.reason,
883-
"plan": plan.to_dict(),
884-
"stage": "processed",
885-
}
886-
if request is None:
887-
disposition["stage"] = "dropped"
888-
disposition["reason"] = artifacts.failure_reason or "not oracle keep"
889-
result.dispositions.append(disposition)
890-
authority.terminalize(
891-
identity, status="dropped", reason=str(disposition["reason"])
892-
)
893-
continue
894-
if not gold_prover(request):
895-
disposition["stage"] = "gold_failed"
896-
disposition["reason"] = "gold proof did not pass"
897-
result.dispositions.append(disposition)
898-
authority.terminalize(
899-
identity, status="gold_failed", reason="gold proof did not pass"
900-
)
901-
continue
902-
calibration = artifacts.calibration_report
903-
if calibration is None:
904-
raise FreshCampaignError(
905-
"candidate has no calibration report before publication"
906-
)
907-
band_decision = calibration.details.get("band_filter")
908-
band_high = (
909-
band_decision.get("band_high")
910-
if isinstance(band_decision, dict)
911-
else None
912-
)
913-
if band_high != 0.5:
914-
raise FreshCampaignError("candidate changed band_high from 0.5")
915-
if calibration.irt_discrimination < 1.0:
916-
raise FreshCampaignError(
917-
"candidate discrimination is below the unchanged threshold"
882+
with docker_name_prefix(f"swe-forge-fresh-{config.run_id}"):
883+
recorder.mark(0)
884+
recorder.mark(1)
885+
recorder.mark(2)
886+
recorder.mark(3)
887+
recorder.mark(4)
888+
for plan in fresh_boltons_plans(config.plans, authority):
889+
if ledger.remaining_cap < config.worst_case_cost_usd:
890+
result.status = "cap_exhausted"
891+
result.reason = "no next request fits within the remaining $50 cap"
892+
break
893+
claim = authority.claim(
894+
plan,
895+
reason="fresh unprocessed boltons hard-rung candidate",
918896
)
919-
try:
920-
if artifacts.oracle_report is None:
921-
raise FreshCampaignError("candidate has no oracle report")
922-
reconciliation = reconcile_recovery_reports(
923-
ledger.accounting_ledger,
924-
artifacts.oracle_report,
925-
artifacts.calibration_report,
926-
require_complete=False,
927-
candidate_identity=identity,
897+
identity = claim.identity
898+
with ledger.call_context(
899+
candidate_identity=identity, stage="fresh-campaign.stage-1-4"
900+
):
901+
artifacts = await processor.process(
902+
plan, config.out_dir / f".{identity}"
903+
)
904+
if ledger.unresolved:
905+
raise FreshCampaignError(
906+
"provider billing is unresolved; publication is forbidden"
907+
)
908+
request = _keep_export_request(artifacts)
909+
disposition: dict[str, object] = {
910+
"identity": identity,
911+
"fresh_reason": claim.reason,
912+
"plan": plan.to_dict(),
913+
"stage": "processed",
914+
}
915+
if request is None:
916+
disposition["stage"] = "dropped"
917+
disposition["reason"] = (
918+
artifacts.failure_reason or "not oracle keep"
919+
)
920+
result.dispositions.append(disposition)
921+
authority.terminalize(
922+
identity, status="dropped", reason=str(disposition["reason"])
923+
)
924+
continue
925+
if not gold_prover(request):
926+
disposition["stage"] = "gold_failed"
927+
disposition["reason"] = "gold proof did not pass"
928+
result.dispositions.append(disposition)
929+
authority.terminalize(
930+
identity,
931+
status="gold_failed",
932+
reason="gold proof did not pass",
933+
)
934+
continue
935+
calibration = artifacts.calibration_report
936+
if calibration is None:
937+
raise FreshCampaignError(
938+
"candidate has no calibration report before publication"
939+
)
940+
band_decision = calibration.details.get("band_filter")
941+
band_high = (
942+
band_decision.get("band_high")
943+
if isinstance(band_decision, dict)
944+
else None
928945
)
929-
except (RecoveryAccountingError, AttributeError) as exc:
930-
raise FreshCampaignError(
931-
"candidate recovery evidence did not reconcile before publication"
932-
) from exc
933-
result.ledger = reconciliation
934-
result.status = "kept"
935-
result.reason = "first newly certified oracle/calibration/gold keep"
936-
pending_request = request
937-
pending_identity = identity
938-
result.dispositions.append({**disposition, "stage": "kept"})
939-
break
940-
else:
941-
result.status = "cap_exhausted"
942-
result.reason = "candidate supply exhausted before a certified keep"
946+
if band_high != 0.5:
947+
raise FreshCampaignError("candidate changed band_high from 0.5")
948+
if calibration.irt_discrimination < 1.0:
949+
raise FreshCampaignError(
950+
"candidate discrimination is below the unchanged threshold"
951+
)
952+
try:
953+
if artifacts.oracle_report is None:
954+
raise FreshCampaignError("candidate has no oracle report")
955+
reconciliation = reconcile_recovery_reports(
956+
ledger.accounting_ledger,
957+
artifacts.oracle_report,
958+
artifacts.calibration_report,
959+
require_complete=False,
960+
candidate_identity=identity,
961+
)
962+
except (RecoveryAccountingError, AttributeError) as exc:
963+
raise FreshCampaignError(
964+
"candidate recovery evidence did not reconcile before publication"
965+
) from exc
966+
result.ledger = reconciliation
967+
result.status = "kept"
968+
result.reason = "first newly certified oracle/calibration/gold keep"
969+
pending_request = request
970+
pending_identity = identity
971+
result.dispositions.append({**disposition, "stage": "kept"})
972+
break
973+
else:
974+
result.status = "cap_exhausted"
975+
result.reason = "candidate supply exhausted before a certified keep"
943976
result.ledger = ledger.reconcile()
944977
if docker_evidence and before is not None:
945978
after = docker_evidence.snapshot()
946-
result.docker_evidence = DockerEvidenceCollector.compare(before, after)
979+
result.docker_evidence["after"] = after.to_dict()
980+
result.docker_evidence["comparison"] = DockerEvidenceCollector.compare(
981+
before, after
982+
)
947983
recorder.mark(5)
948984
result.exit_status = 0
949985
result.stage_markers = tuple(recorder.markers)
@@ -978,7 +1014,10 @@ async def run_fresh_campaign(
9781014
recorder.complete(1)
9791015
if docker_evidence and before is not None:
9801016
after = docker_evidence.snapshot()
981-
result.docker_evidence = DockerEvidenceCollector.compare(before, after)
1017+
result.docker_evidence["after"] = after.to_dict()
1018+
result.docker_evidence["comparison"] = DockerEvidenceCollector.compare(
1019+
before, after
1020+
)
9821021
raise
9831022

9841023

src/swe_forge/forge/gold_eval.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737

3838
import yaml # type: ignore[import-untyped]
3939

40+
from swe_forge.execution.sandbox import scoped_docker_name
4041
from swe_forge.forge.export import FORGE_DIR, REPO_DIR
4142

4243
#: Independent fresh-container runs per task. >=2 proves determinism (no flip).
@@ -156,7 +157,7 @@ def resolve_eval_image(task_dir: Path | str) -> str:
156157

157158
def _container_name(prefix: str, task_id: str) -> str:
158159
slug = _NAME_SANITIZE_RE.sub("-", task_id)[:32].strip("-_.") or "task"
159-
return f"{prefix}-{slug}-{uuid.uuid4().hex[:8]}"
160+
return scoped_docker_name(f"{prefix}-{slug}-{uuid.uuid4().hex[:8]}")
160161

161162

162163
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)