Skip to content
Merged
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
33 changes: 33 additions & 0 deletions services/controller/app/run_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
resolve_run_max_ticks,
resolve_run_runtime_limit,
)
from app.run_frontend_ports import apply_frontend_port, reserve_frontend_port
from app.run_launcher import RunLaunchConfig, run_launcher
from app import run_public_ops
from app.run_read_model import build_run_response
Expand Down Expand Up @@ -101,6 +102,37 @@ async def create_bound_run(
)
db.add(db_run)
db.flush()
try:
launch_manifest = run_launcher.resolve_environment_launch(
str(normalized_env_config.get("environment_id") or "").strip() or environment_id
)
frontend_port = reserve_frontend_port(
db,
run_id=db_run.run_id,
environment_has_frontend=bool(launch_manifest.get("frontend_service")),
)
apply_frontend_port(
run_id=db_run.run_id,
environment_config=normalized_env_config,
snapshot=snapshot,
frontend_port=frontend_port,
)
except Exception as port_error:
db.rollback()
db_run.status = RunStatusEnum.FAILED.value
db_run.ended_at = datetime.utcnow()
emit_run_terminal_event(
db=db,
run=db_run,
terminal_status=RunStatusEnum.FAILED.value,
terminal_reason=f"Failed to reserve run frontend port: {port_error}",
terminal_source="create_run_frontend_port",
)
db.commit()
raise HTTPException(
status_code=500,
detail=f"Failed to reserve run frontend port: {port_error}",
) from port_error
run_binding.upsert_run_binding(
db,
run=db_run,
Expand Down Expand Up @@ -161,6 +193,7 @@ async def create_bound_run(
seed=request.seed,
resolved_bundle_hash=resolved_bundle_hash,
api_key=effective_api_key,
frontend_port=frontend_port,
)

async def mark_run_failed_and_stop(
Expand Down
103 changes: 103 additions & 0 deletions services/controller/app/run_frontend_ports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Helpers for reserving per-run environment frontend ports."""

from __future__ import annotations

from typing import Any

from sqlalchemy.orm import Session

from app.database import RunBinding as RunBindingDB
from app.run_launcher import RunLauncher


def _coerce_port(value: Any) -> int | None:
try:
if value is None:
return None
port = int(value)
except (TypeError, ValueError):
return None
if port <= 0:
return None
return port


def extract_frontend_port(*payloads: Any) -> int | None:
"""Read a reserved frontend port from run binding payloads."""
for payload in payloads:
if not isinstance(payload, dict):
continue
launch = payload.get("launch") if isinstance(payload.get("launch"), dict) else {}
for key in ("frontend_port", "environment_frontend_port"):
port = _coerce_port(launch.get(key))
if port is not None:
return port
frontend_url = str(launch.get("frontend_url") or launch.get("environment_frontend_url") or "").strip()
if frontend_url.startswith("http://localhost:"):
port = _coerce_port(frontend_url.rsplit(":", 1)[-1])
if port is not None:
return port
return None


def has_reserved_frontend(environment_config: dict[str, Any] | None, snapshot: dict[str, Any] | None = None) -> bool:
"""Return True when a run binding already carries a frontend reservation."""
return extract_frontend_port(environment_config or {}, snapshot or {}) is not None


def reserve_frontend_port(
db: Session,
*,
run_id: str,
environment_has_frontend: bool,
) -> int | None:
"""Reserve a stable frontend port for a run.

Ports are reserved across all bound runs so stopped runs can still be
restarted later without colliding with newer runs.
"""
if not environment_has_frontend:
return None

reserved_ports: set[int] = set()
for binding in db.query(RunBindingDB).all():
if str(binding.run_id) == str(run_id):
continue
port = extract_frontend_port(
binding.environment_config if isinstance(binding.environment_config, dict) else {},
binding.snapshot if isinstance(binding.snapshot, dict) else {},
)
if port is not None:
reserved_ports.add(port)

preferred_port = RunLauncher.get_environment_frontend_port(run_id)
base = RunLauncher.FRONTEND_PORT_BASE
span = RunLauncher.FRONTEND_PORT_SPAN
start_offset = preferred_port - base

for step in range(span):
candidate = base + ((start_offset + step) % span)
if candidate not in reserved_ports:
return candidate

raise ValueError("No available reserved frontend ports remain in the configured span")


def apply_frontend_port(
*,
run_id: str,
environment_config: dict[str, Any],
snapshot: dict[str, Any],
frontend_port: int | None,
) -> None:
"""Persist a resolved frontend port into environment config and snapshot."""
if frontend_port is None:
return

frontend_url = RunLauncher.get_environment_frontend_url(run_id, frontend_port=frontend_port)

for payload in (environment_config, snapshot):
launch = payload.get("launch") if isinstance(payload.get("launch"), dict) else {}
launch["frontend_port"] = int(frontend_port)
launch["frontend_url"] = frontend_url
payload["launch"] = launch
57 changes: 47 additions & 10 deletions services/controller/app/run_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class RunLaunchConfig(BaseModel):
seed: Optional[int] = Field(None, description="Random seed")
resolved_bundle_hash: Optional[str] = Field(None, description="Bundle hash")
api_key: Optional[str] = Field(None, description="LLM API key for agents")
frontend_port: Optional[int] = Field(None, description="Reserved host port for environment frontend")


class RunLauncher:
Expand Down Expand Up @@ -124,9 +125,18 @@ def get_environment_frontend_port(cls, run_id: str, namespace: Optional[str] = N
return cls.FRONTEND_PORT_BASE + offset

@classmethod
def get_environment_frontend_url(cls, run_id: str, namespace: Optional[str] = None) -> str:
def get_environment_frontend_url(
cls,
run_id: str,
namespace: Optional[str] = None,
frontend_port: Optional[int] = None,
) -> str:
"""Get browser-facing frontend URL for a run."""
return f"http://localhost:{cls.get_environment_frontend_port(run_id, namespace=namespace)}"
resolved_port = int(frontend_port) if frontend_port is not None else cls.get_environment_frontend_port(
run_id,
namespace=namespace,
)
return f"http://localhost:{resolved_port}"

@classmethod
def default_environment_image_repository(cls, environment_id: str, asset_kind: str) -> str:
Expand Down Expand Up @@ -378,6 +388,7 @@ def get_service_urls(
self,
run_id: str,
environment_id: Optional[str] = None,
frontend_port: Optional[int] = None,
) -> Dict[str, str]:
"""Get service URLs for a run.

Expand Down Expand Up @@ -406,7 +417,10 @@ def get_service_urls(
urls["environment_frontend_internal"] = (
f"http://{frontend_service['container_prefix']}-{run_id}:{frontend_service['port']}"
)
urls["environment_frontend"] = self.get_environment_frontend_url(run_id)
urls["environment_frontend"] = self.get_environment_frontend_url(
run_id,
frontend_port=frontend_port,
)

return urls

Expand All @@ -431,7 +445,11 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op
environment_service = launch_config["environment_service"]
agent_service = launch_config["agent_worker_service"]
frontend_service = launch_config.get("frontend_service")
frontend_port = self.get_environment_frontend_port(run_id)
frontend_port = (
int(config.frontend_port)
if config.frontend_port is not None
else self.get_environment_frontend_port(run_id)
)
resolved_llm_provider = (
"dummy"
if str(config.api_key or "").strip().lower() == "dummy"
Expand All @@ -450,7 +468,10 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op
"ENV_STATE_KEY": state_key,
"ENV_STATE_PATH": state_path,
"ENV_FRONTEND_PORT": str(frontend_port),
"ENV_FRONTEND_URL": self.get_environment_frontend_url(config.run_id),
"ENV_FRONTEND_URL": self.get_environment_frontend_url(
config.run_id,
frontend_port=frontend_port,
),
"DATABASE_URL": database_url,
"SEED": str(config.seed) if config.seed else "",
"RESOLVED_BUNDLE_HASH": config.resolved_bundle_hash or "",
Expand All @@ -475,7 +496,10 @@ def generate_run_override(self, config: RunLaunchConfig, oracle_bundle_paths: Op
state_key=state_key,
state_path=state_path,
frontend_port=frontend_port,
frontend_url=self.get_environment_frontend_url(config.run_id),
frontend_url=self.get_environment_frontend_url(
config.run_id,
frontend_port=frontend_port,
),
)
)

Expand Down Expand Up @@ -690,7 +714,11 @@ def launch_run(self, config: RunLaunchConfig) -> Dict:
state_key = f"{config.environment_id}:{config.run_id}"
state_path = f"/app/data/state/{config.run_id}"
database_url = f"sqlite:////app/data/state/{config.run_id}/environment.db"
frontend_port = self.get_environment_frontend_port(config.run_id)
frontend_port = (
int(config.frontend_port)
if config.frontend_port is not None
else self.get_environment_frontend_port(config.run_id)
)
resolved_llm_provider = (
"dummy"
if str(config.api_key or "").strip().lower() == "dummy"
Expand All @@ -705,10 +733,16 @@ def launch_run(self, config: RunLaunchConfig) -> Dict:
"ENV_STATE_PATH": state_path,
"DATABASE_URL": database_url,
"ENV_FRONTEND_PORT": str(frontend_port),
"ENV_FRONTEND_URL": self.get_environment_frontend_url(config.run_id),
"ENV_FRONTEND_URL": self.get_environment_frontend_url(
config.run_id,
frontend_port=frontend_port,
),
"ENV_CONTAINER_PREFIX": config.environment_id,
"ENVIRONMENT_FRONTEND_PORT": str(frontend_port),
"ENVIRONMENT_FRONTEND_URL": self.get_environment_frontend_url(config.run_id),
"ENVIRONMENT_FRONTEND_URL": self.get_environment_frontend_url(
config.run_id,
frontend_port=frontend_port,
),
"ENVIRONMENT_NAME": config.environment_id,
"SEED": str(config.seed) if config.seed else "",
"RESOLVED_BUNDLE_HASH": config.resolved_bundle_hash or "",
Expand All @@ -723,7 +757,10 @@ def launch_run(self, config: RunLaunchConfig) -> Dict:
state_key=state_key,
state_path=state_path,
frontend_port=frontend_port,
frontend_url=self.get_environment_frontend_url(config.run_id),
frontend_url=self.get_environment_frontend_url(
config.run_id,
frontend_port=frontend_port,
),
)
)

Expand Down
25 changes: 23 additions & 2 deletions services/controller/app/run_read_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from app.models import Run, RunStatus
from app.run_binding import build_run_context
from app.run_config import resolve_run_runtime_limit
from app.run_frontend_ports import extract_frontend_port
from app.run_launcher import RunLaunchConfig, run_launcher


Expand Down Expand Up @@ -102,10 +103,15 @@ def resolve_run_service_urls(run: RunDB, db: Session) -> dict[str, str]:
context = build_run_context(run, db)
environment_id = resolve_environment_id_for_run(run, db)
has_frontend = _context_has_frontend(context)
frontend_port = extract_frontend_port(
context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {},
context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {},
)
try:
return run_launcher.get_service_urls(
run_id=run.run_id,
environment_id=environment_id,
frontend_port=frontend_port,
)
except Exception as exc:
logger.warning(
Expand All @@ -119,14 +125,21 @@ def resolve_run_service_urls(run: RunDB, db: Session) -> dict[str, str]:
"agent_worker": settings.get_agent_worker_url(run.run_id, environment_id=environment_id),
}
if has_frontend:
fallback_urls["environment_frontend"] = run_launcher.get_environment_frontend_url(run.run_id)
fallback_urls["environment_frontend"] = run_launcher.get_environment_frontend_url(
run.run_id,
frontend_port=frontend_port,
)
return fallback_urls


def build_restart_launch_config(run: RunDB, db: Session) -> RunLaunchConfig:
context = build_run_context(run, db)
environment_id = resolve_environment_id_for_run(run, db)
effective_api_key = settings.openrouter_api_key or None
frontend_port = extract_frontend_port(
context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {},
context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {},
)

return RunLaunchConfig(
run_id=run.run_id,
Expand All @@ -137,6 +150,7 @@ def build_restart_launch_config(run: RunDB, db: Session) -> RunLaunchConfig:
or None
),
api_key=effective_api_key,
frontend_port=frontend_port,
)


Expand All @@ -150,6 +164,10 @@ def build_run_response(
context = build_run_context(run, db)
runtime_limit_minutes, _ = resolve_run_runtime_limit(context.get("environment_config"))
has_frontend = _context_has_frontend(context)
frontend_port = extract_frontend_port(
context.get("environment_config") if isinstance(context.get("environment_config"), dict) else {},
context.get("snapshot") if isinstance(context.get("snapshot"), dict) else {},
)

service_urls = resolve_run_service_urls(run, db)
environment_id = resolve_environment_id_for_run(run, db)
Expand All @@ -159,7 +177,10 @@ def build_run_response(
)
frontend_url = None
if has_frontend:
frontend_url = service_urls.get("environment_frontend") or run_launcher.get_environment_frontend_url(run.run_id)
frontend_url = service_urls.get("environment_frontend") or run_launcher.get_environment_frontend_url(
run.run_id,
frontend_port=frontend_port,
)
return Run(
run_id=run.run_id,
resolved_bundle_hash=run.resolved_bundle_hash,
Expand Down
Loading
Loading