2222from pathlib import Path
2323from typing import Protocol
2424
25+ from swe_forge .execution .sandbox import docker_name_prefix
2526from swe_forge .forge .calibrate .filter import DEFAULT_BAND_FILTER , BandFilterConfig
2627from 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 )
516525class 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
522538def _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
0 commit comments