refactor(backend): base owns the gateway-attach flow, fail hard (0081 review)

Addresses review on #519 (@didericis 6143 + the fail-hard direction over the
codex skip).

Base owns the flow (6143 / ADR 0006). `attach_bottled_agents_to_gateway()` is
now a concrete method on `BottleBackend` that delegates to
`gateway_attach.reconcile_running_bottles`; backends override only three
primitives — `_gateway_attach_resources()`, `_running_bottles()`,
`_attach_bottle_to_gateway()`. The shared control flow + error policy live in
one place so a backend can't drift onto a bespoke loop or a silent skip. Keeps
the reconcile flow + `GatewayAttachResources` out of `backend/base.py` (the
size guardrail) in a dedicated `backend/gateway_attach.py`. New ADR 0006 records
the "shared behaviour in the base backend, subclasses override primitives"
theme.

Fail hard, never skip (the maintainer's direction over the codex review's
skip). Any attach failure now aborts bring-up instead of being logged and
skipped: a bottle that silently can't reach the fresh gateway (its egress just
starts failing TLS) is worse than a loud failure. Every bottle is attempted and
the failures are raised together (aggregate `InfraLaunchError`) so one bring-up
surfaces the full blast radius. Resource gathering (the CA fetch) also fails
hard. PRD 0081 goal + design updated to match (reversed from the earlier
"tolerate per-bottle failures" draft).

Bumps the base.py guardrail cap 580->600 for the new contract surface (the
delegator + 3 abstract primitives); the flow itself lives in gateway_attach.py.

Refs #516, #519.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 01:50:06 +00:00
parent 187d012ca8
commit 9ac5b19322
13 changed files with 420 additions and 251 deletions
+31 -19
View File
@@ -21,7 +21,7 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Generator, Generic, Sequence, TypeVar
from typing import TYPE_CHECKING, Generator, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider
from ..egress import EgressPlan
@@ -35,6 +35,9 @@ from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir
if TYPE_CHECKING:
from .gateway_attach import GatewayAttachResources
class BackendStatus(enum.IntEnum):
"""Return codes for BottleBackend.status(). READY == 0 so callsites
@@ -270,6 +273,11 @@ class Bottle(ABC):
PlanT = TypeVar("PlanT", bound=BottlePlan)
CleanupT = TypeVar("CleanupT", bound=BottleCleanupPlan)
# The backend-specific handle for one running bottle the reconcile attaches to
# the gateway (a run dir for firecracker, a container name for docker/macOS).
# Registry-style callers that don't care about the handle bind it to `Any`
# (`BottleBackend[Any, Any, Any]`).
AttachTargetT = TypeVar("AttachTargetT")
class InfraLaunchError(RuntimeError):
@@ -290,7 +298,7 @@ class BottleImages:
sidecar: str | Path = ""
class BottleBackend(ABC, Generic[PlanT, CleanupT]):
class BottleBackend(ABC, Generic[PlanT, CleanupT, AttachTargetT]):
"""Abstract base for selectable bottle backends. Concrete subclasses
(e.g. DockerBottleBackend) own their own prepare/launch impls.
Parameterized over the backend's concrete plan + cleanup-plan types
@@ -518,26 +526,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
compose ls`; firecracker cross-references its running gateway
containers against per-bottle metadata."""
@abstractmethod
def attach_bottled_agents_to_gateway(self) -> None:
"""Reconcile every already-running bottle against the freshly-(re)booted
gateway (PRD 0081). The host calls this on the gateway bring-up path —
the cold-boot branch only, never on an adopt of a healthy current
gateway — so a gateway rebuild/restart doesn't silently break running
bottles' gateway-dependent state.
"""Reconcile every running bottle against the freshly-(re)booted gateway
on the cold-boot bring-up path (PRD 0081). The shared flow + fail-hard
policy live in `gateway_attach`; backends override only the three
primitives below (ADR 0006)."""
from .gateway_attach import reconcile_running_bottles
reconcile_running_bottles(self)
Reconciling running bottles is a backend responsibility: only the
backend can enumerate its agents and reach them (firecracker over SSH,
docker/macOS over `exec`/`cp`), so every backend must implement it —
cross-backend by construction. Each per-bottle step is best-effort: one
unreachable or malformed bottle is logged and skipped so it never blocks
the others or the gateway coming up.
@abstractmethod
def _gateway_attach_resources(self) -> "GatewayAttachResources":
"""Gather what every bottle needs to (re)attach to the current gateway
(the CA now). Raises if the gateway isn't reachable (aborts reconcile)."""
Currently reconciles the **CA**: it replaces each agent's trusted CA
with the current gateway CA (unconditional — there is one gateway, so no
fingerprint match is needed), which rotates the CA for free on every
bring-up. Git-gate re-provision and egress-token restore fold into this
same reconcile on the same trigger (see #516)."""
@abstractmethod
def _running_bottles(self) -> Sequence[AttachTargetT]:
"""The live bottles as backend handles for `_attach_bottle_to_gateway`.
Raises if the live set can't be determined (fail hard, no partial set)."""
@abstractmethod
def _attach_bottle_to_gateway(
self, bottle: AttachTargetT, resources: "GatewayAttachResources",
) -> None:
"""(Re)attach one bottle to the current gateway (SSH / `exec`+`cp`).
Raises `InfraLaunchError` naming the bottle on failure."""
@classmethod
@abstractmethod