refactor(image-cache): replace warn_if_stale with StaleImageError; add launch template
lint / lint (push) Successful in 2m0s
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Failing after 1m7s

- image_cache: StaleImageError exception + check_stale/check_stale_path (raise instead of warn)
- BottleBackend.launch: template method (skip_stale flag) that calls _image_stale_checks then _launch_impl
- Each backend: _image_stale_checks delegates to a stale_checks() function in its launch module; _launch_impl replaces launch override
- macos_container: adds image_created_at to util, cached-image support in _build_images, stale_checks
- cli/start.py: catches StaleImageError, prompts interactively, retries with skip_stale=True; headless mode dies on it
This commit is contained in:
2026-07-09 18:39:44 +00:00
parent 83bd20f9b3
commit 60b394e4fb
13 changed files with 200 additions and 80 deletions
+20 -3
View File
@@ -36,10 +36,10 @@ import os
import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
from typing import Any, Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan
@@ -435,8 +435,25 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
prompt file, Dockerfile path, and guest home all live on
`agent_provision_plan` — the source of truth."""
@contextmanager
def launch(
self, plan: PlanT, *, skip_stale: bool = False
) -> Generator[Bottle, None, None]:
"""Template: optionally check for stale cached images, then delegate
to `_launch_impl`. Pass `skip_stale=True` to bypass the stale check
(used by the interactive CLI after the operator confirms)."""
if not skip_stale:
self._image_stale_checks(plan)
with self._launch_impl(plan) as bottle:
yield bottle
def _image_stale_checks(self, plan: PlanT) -> None:
"""Raise StaleImageError if any cached image used by this plan is stale.
No-op default; backends override to call the shared `check_stale*`
helpers on their image/artifact timestamps."""
@abstractmethod
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
def _launch_impl(self, plan: PlanT) -> AbstractContextManager[Bottle]:
"""Build/run the bottle and yield a handle; tear down on exit."""
def provision(self, plan: PlanT, bottle: "Bottle") -> str | None:
+4 -1
View File
@@ -85,8 +85,11 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
stage_dir=stage_dir,
)
def _image_stale_checks(self, plan: DockerBottlePlan) -> None:
_launch.stale_checks(plan)
@contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
def _launch_impl(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle
+19 -14
View File
@@ -41,7 +41,7 @@ from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...image_cache import warn_if_stale
from ...image_cache import check_stale
from ...log import die, info, warn
from . import network as network_mod
from . import util as docker_mod
@@ -105,11 +105,6 @@ def launch(
cached_policy = plan.spec.image_policy == "cached"
if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}")
if cached_policy:
warn_if_stale(
f"agent image {committed!r}",
docker_mod.image_created_at(committed),
)
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
@@ -127,14 +122,6 @@ def launch(
)
info(f"using cached agent image {plan.image!r}")
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
warn_if_stale(
f"agent image {plan.image!r}",
docker_mod.image_created_at(plan.image),
)
warn_if_stale(
f"sidecar image {SIDECAR_BUNDLE_IMAGE!r}",
docker_mod.image_created_at(SIDECAR_BUNDLE_IMAGE),
)
else:
docker_mod.build_image(
plan.image, _REPO_DIR,
@@ -236,3 +223,21 @@ def launch(
yield bottle
finally:
teardown()
def stale_checks(plan: DockerBottlePlan) -> None:
"""Raise StaleImageError if a cached image is older than the configured
threshold. Only runs when image_policy is 'cached'. Called by the backend
class's _image_stale_checks before _launch_impl starts any resources."""
if plan.spec.image_policy != "cached":
return
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
check_stale(f"agent image {committed!r}", docker_mod.image_created_at(committed))
return
for label, ref in [
("agent image", plan.image),
("sidecar image", SIDECAR_BUNDLE_IMAGE),
]:
if docker_mod.image_exists(ref):
check_stale(f"{label} {ref!r}", docker_mod.image_created_at(ref))
@@ -67,8 +67,11 @@ class MacosContainerBottleBackend(
stage_dir=stage_dir,
)
def _image_stale_checks(self, plan: MacosContainerBottlePlan) -> None:
_launch.stale_checks(plan)
@contextmanager
def launch(
def _launch_impl(
self, plan: MacosContainerBottlePlan
) -> Generator[MacosContainerBottle, None, None]:
with _launch.launch(plan, provision=self.provision) as bottle:
+39 -14
View File
@@ -32,6 +32,7 @@ from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...image_cache import check_stale
from ...log import die, info, warn
from ...supervise import DB_PATH_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde
@@ -149,29 +150,53 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
container_mod.build_image(
SIDECAR_BUNDLE_IMAGE,
_REPO_DIR,
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
)
cached = plan.spec.image_policy == "cached"
committed = read_committed_image(plan.slug)
if committed and container_mod.image_exists(committed):
info(f"using committed image {committed!r}")
if not cached:
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
return dataclasses.replace(
plan,
agent_provision=dataclasses.replace(
plan.agent_provision,
image=committed,
),
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
)
container_mod.build_image(
plan.image,
_REPO_DIR,
dockerfile=plan.dockerfile_path,
)
if cached:
if not container_mod.image_exists(plan.image):
die(
f"cached agent image {plan.image!r} not found; "
"run without --cached-images to build it"
)
if not container_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
die(
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
"run without --cached-images to build it"
)
info(f"using cached agent image {plan.image!r}")
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
return plan
container_mod.build_image(SIDECAR_BUNDLE_IMAGE, _REPO_DIR, dockerfile=SIDECAR_BUNDLE_DOCKERFILE)
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
return plan
def stale_checks(plan: MacosContainerBottlePlan) -> None:
"""Raise StaleImageError if a cached image is older than the configured
threshold. Only runs when image_policy is 'cached'. Called by the backend
class's _image_stale_checks before _launch_impl starts any resources."""
if plan.spec.image_policy != "cached":
return
committed = read_committed_image(plan.slug)
if committed and container_mod.image_exists(committed):
check_stale(f"agent image {committed!r}", container_mod.image_created_at(committed))
return
for label, ref in [
("agent image", plan.image),
("sidecar image", SIDECAR_BUNDLE_IMAGE),
]:
if container_mod.image_exists(ref):
check_stale(f"{label} {ref!r}", container_mod.image_created_at(ref))
def _create_networks(
internal_network: str,
egress_network: str,
@@ -10,6 +10,7 @@ import shutil
import subprocess
import tempfile
import time
from datetime import datetime, timezone
from typing import Iterable
from ...log import die, info
@@ -458,6 +459,39 @@ def image_id(ref: str) -> str:
raise AssertionError("unreachable")
def image_created_at(ref: str) -> datetime:
"""Return the image creation timestamp as an aware UTC datetime.
Parses the `created` field from `container image inspect` JSON output."""
result = subprocess.run(
[_CONTAINER, "image", "inspect", ref],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
die(
f"container image inspect for {ref!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
try:
data = json.loads(result.stdout or "{}")
except json.JSONDecodeError as exc:
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
if isinstance(data, list) and data:
data = data[0]
if isinstance(data, dict):
value = data.get("created") or data.get("Created")
if isinstance(value, str) and value:
try:
ts = value.rstrip("Z")
return datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
except ValueError:
pass
die(f"container image inspect for {ref!r} did not include a creation timestamp")
raise AssertionError("unreachable")
def save(ref: str, output: str) -> None:
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
+4 -1
View File
@@ -77,8 +77,11 @@ class SmolmachinesBottleBackend(
stage_dir=stage_dir,
)
def _image_stale_checks(self, plan: SmolmachinesBottlePlan) -> None:
_launch.stale_checks(plan)
@contextmanager
def launch(
def _launch_impl(
self, plan: SmolmachinesBottlePlan
) -> Generator[SmolmachinesBottle, None, None]:
with _launch.launch(plan, provision=self.provision) as bottle:
+27 -4
View File
@@ -45,7 +45,7 @@ from ...git_gate import (
provision_git_gate_dynamic_keys,
revoke_git_gate_provisioned_keys,
)
from ...image_cache import warn_if_stale_path
from ...image_cache import check_stale_path
from ...log import die, info, warn
from ...bottle_state import (
egress_state_dir,
@@ -471,8 +471,6 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
committed_path = Path(committed)
if committed_path.is_file():
info(f"using committed smolmachine {str(committed_path)!r}")
if _image_policy(plan) == "cached":
warn_if_stale_path("agent smolmachine artifact", committed_path)
return committed_path
if _image_policy(plan) == "cached":
@@ -488,6 +486,32 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
)
def stale_checks(plan: SmolmachinesBottlePlan) -> None:
"""Raise StaleImageError if a cached smolmachine artifact is older than the
configured threshold. Only runs when image_policy is 'cached'. Checks the
committed agent artifact first, then the cache-keyed agent and sidecar
artifacts. Called by the backend class's _image_stale_checks before any
resources are allocated."""
if _image_policy(plan) != "cached":
return
committed = read_committed_image(plan.slug)
if committed:
committed_path = Path(committed)
if committed_path.is_file():
check_stale_path("agent smolmachine artifact", committed_path)
elif docker_mod.image_exists(plan.agent_image):
digest = docker_mod.image_id(plan.agent_image).split(":", 1)[-1][:16]
artifact = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
if artifact.is_file():
check_stale_path("agent smolmachine artifact", artifact)
sidecar_image = _bundle.SIDECAR_BUNDLE_IMAGE
if docker_mod.image_exists(sidecar_image):
digest = docker_mod.image_id(sidecar_image).split(":", 1)[-1][:16]
sidecar_artifact = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
if sidecar_artifact.is_file():
check_stale_path("sidecar smolmachine artifact", sidecar_artifact)
def _image_policy(plan: object) -> str:
spec = getattr(plan, "spec", None)
return str(getattr(spec, "image_policy", "fresh"))
@@ -513,7 +537,6 @@ def _cached_smolmachine(image_ref: str, *, label: str) -> Path:
"run without --cached-images to build it"
)
info(f"using cached {label} smolmachine {str(sidecar)!r}")
warn_if_stale_path(f"{label} smolmachine artifact", sidecar)
return sidecar
+32 -23
View File
@@ -36,6 +36,7 @@ from ..bottle_state import (
is_preserved,
mark_preserved,
)
from ..image_cache import StaleImageError
from ..log import info, die
from ..manifest import Manifest, ManifestIndex
from ._common import PROG, USER_CWD, read_tty_line
@@ -555,30 +556,38 @@ def _launch_bottle(
return 0
backend = get_bottle_backend(backend_name)
with backend.launch(plan) as bottle:
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
extra_args: tuple[str, ...] = ()
if headless_prompt_text:
extra_args = tuple(
get_provider(agent_provider_template).headless_prompt(
headless_prompt_text
skip_stale = False
while True:
try:
with backend.launch(plan, skip_stale=skip_stale) as bottle:
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
extra_args: tuple[str, ...] = ()
if headless_prompt_text:
extra_args = tuple(
get_provider(agent_provider_template).headless_prompt(
headless_prompt_text
)
)
exit_code = attach_agent(
bottle,
agent_provider_template=agent_provider_template,
startup_args=plan.agent_provision.startup_args + extra_args,
)
)
exit_code = attach_agent(
bottle,
agent_provider_template=agent_provider_template,
startup_args=plan.agent_provision.startup_args + extra_args,
)
info(
f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed"
)
# While the container is still alive: always snapshot the
# transcript and — if the agent exited non-zero — mark
# the state for preservation. This picks up crashes /
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code)
info(
f"session ended (exit {exit_code}); "
f"container {bottle.name} will be removed"
)
if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code)
break
except StaleImageError as exc:
if assume_yes:
die(str(exc))
sys.stderr.write(f"bot-bottle: {exc}\nLaunch anyway? [y/N] ")
sys.stderr.flush()
if read_tty_line() not in ("y", "Y", "yes", "YES"):
break
skip_stale = True
return 0
finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
+15 -11
View File
@@ -1,4 +1,4 @@
"""Shared helpers for cached-image quickstart warnings."""
"""Shared helpers for cached-image quickstart stale checks."""
from __future__ import annotations
@@ -7,13 +7,19 @@ from pathlib import Path
try:
from .config_store import ConfigStore
from .log import warn
except ImportError:
from config_store import ConfigStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
from log import warn # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
def warn_if_stale(label: str, created_at: datetime) -> None:
class StaleImageError(Exception):
"""Raised when a cached image or artifact exceeds the configured staleness
threshold. Callers can catch this to prompt interactively; headless paths
let it propagate as a fatal error."""
def check_stale(label: str, created_at: datetime) -> None:
"""Raise StaleImageError if `created_at` is older than the configured
stale-warning threshold. Negative threshold disables the check."""
threshold_days = ConfigStore().cached_image_stale_warning_days()
if threshold_days < 0:
return
@@ -22,17 +28,15 @@ def warn_if_stale(label: str, created_at: datetime) -> None:
age = now - created
if age.total_seconds() <= threshold_days * 86400:
return
warn(
raise StaleImageError(
f"cached {label} is {age.days} day(s) old; "
"quickstart does not verify it matches the current Dockerfile/context"
)
def warn_if_stale_path(label: str, path: Path) -> None:
warn_if_stale(
label,
datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
)
def check_stale_path(label: str, path: Path) -> None:
"""Raise StaleImageError if `path`'s mtime exceeds the staleness threshold."""
check_stale(label, datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc))
__all__ = ["warn_if_stale", "warn_if_stale_path"]
__all__ = ["StaleImageError", "check_stale", "check_stale_path"]
@@ -105,10 +105,6 @@ class TestLaunchCommittedImage(unittest.TestCase):
launch_mod.docker_mod, "image_exists", return_value=image_present,
), mock.patch.object(
launch_mod.docker_mod, "build_image", side_effect=fake_build,
), mock.patch.object(
launch_mod.docker_mod, "image_created_at",
), mock.patch.object(
launch_mod, "warn_if_stale",
), mock.patch.object(
launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
+2 -2
View File
@@ -81,7 +81,7 @@ def _plan(
provisioned_env={"CODEX_HOME": "/run/codex-home"},
)
return cast(MacosContainerBottlePlan, SimpleNamespace(
spec=SimpleNamespace(),
spec=SimpleNamespace(image_policy="fresh"),
manifest=_MANIFEST,
stage_dir=stage_dir,
slug="dev-abc",
@@ -292,7 +292,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
return MacosContainerBottlePlan(
spec=cast(BottleSpec, SimpleNamespace()),
spec=cast(BottleSpec, SimpleNamespace(image_policy="fresh")),
manifest=_MANIFEST,
stage_dir=stage_dir,
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
@@ -206,8 +206,6 @@ class TestAgentFromPath(unittest.TestCase):
), patch.object(
_launch_mod.docker_mod, "image_id",
return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object(
_launch_mod, "warn_if_stale_path",
), patch.object(
_launch_mod, "_ensure_smolmachine",
) as ensure: