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
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/agent_env/computers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ names another, never on the sandbox it deploys on. A compose of a disk also comp
unless that's built. A deploy that boots a form no compose has started, a disk named by `env_params` included, composes
it first on that same provider, saying which, reusing what's built, as every build is reused by its key; deploys of one
env at once compose it once, the others
waiting for it, a deploy that finds it building included. In `agent-env run` it says why and each stage as it goes, and
waiting for it, a deploy that finds it building included. A container built but left unverified though the deploy's
sandbox runs it natively, as one is when a setting its probe boots with doesn't resolve, is composed again the same way:
its image reused and probed, and booted only once ready; a deploy that still has no ready build names why each such
container wasn't probed. In `agent-env run` it says why and each stage as it goes, and
a deploy waiting for another's compose says what it waits for and since when. A local run doesn't compose a registered
env's form at deploy where a configured store isn't the per-user one, since the run's fence refuses its record: the
deploy refuses for want of a ready build and names `agent-env env build`.
Expand Down
8 changes: 5 additions & 3 deletions src/agent_env/env/envs/computer/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ def computer_artifact(self) -> ComputerArtifact:
return ComputerArtifact.get(self.metadata["computer"]["id"], self.metadata["computer"]["version"])

async def compose_unstarted(self, sandbox_provider: SandboxProvider, form: Optional[str] = None) -> None:
"""Compose first what a deploy on ``sandbox_provider`` boots that no compose started, once for deploys at once,
"""Compose first what a deploy on ``sandbox_provider`` boots that no compose started, or a container it runs
natively that was built but not probed (``composer.unprobed``), once for deploys at once,
on the env's default sandbox provider, this machine unless the config names another, never on the deploy's,
unless the run's fence would refuse the record (rules in ``computers/README.md``). A cancelled deploy holds the
lock until its compose's thread, which no cancel stops, ends, so the next deploy waits for what it builds."""
Expand All @@ -235,8 +236,9 @@ async def waiting(seconds: float) -> None:
composed = self.metadata.get("composed", {})
booting, building = (" or ".join(type(link).__name__ for link in provider.links)
for provider in (sandbox_provider, on.provider()))
why = (f"an agent-env that composes differently built {them}" if any(
stale(composed.get(name, {}).get(arch)) for name, arch in pairs)
records = [composed.get(name, {}).get(arch) for name, arch in pairs]
why = (f"an agent-env that composes differently built {them}" if any(map(stale, records))
else f"no compose probed {them} on {booting} yet" if all(records)
else f"no compose built {them} for {booting} yet")
say(f"composing {_pairs(pairs)} before this deploy on {building} ({why}); later deploys reuse {them}")
composing = asyncio.ensure_future(asyncio.to_thread(composer.compose, self, pairs, on))
Expand Down
28 changes: 23 additions & 5 deletions src/agent_env/env/envs/computer/utils/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,34 @@ def left(form: str, arch: str, record: dict | None) -> bool:

def unstarted(env: ComputerEnv, sandbox_provider: SandboxProvider, form: str | None = None) -> list[tuple[str, str]]:
"""The pairs a deploy on ``sandbox_provider`` boots that no compose has started, as a run for another sandbox
leaves them, or that an agent-env that composes differently recorded."""
return _pairs(env, booted_forms(env, sandbox_provider, form),
lambda form, arch, record: record is None or stale(record))
leaves them, that an agent-env that composes differently recorded, or that are ``unprobed`` there."""
return _pairs(env, booted_forms(env, sandbox_provider, form), lambda form, arch, record: (
record is None or stale(record) or _unprobed(env, sandbox_provider, form, arch, record)))


def unstarted_or_building(env: ComputerEnv, sandbox_provider: SandboxProvider,
form: str | None = None) -> list[tuple[str, str]]:
"""Those, and the ones a compose is building now, which a deploy waits for."""
return _pairs(env, booted_forms(env, sandbox_provider, form),
lambda form, arch, record: record is None or stale(record) or record.get("status") == "building")
return _pairs(env, booted_forms(env, sandbox_provider, form), lambda form, arch, record: (
record is None or stale(record) or record.get("status") == "building"
or _unprobed(env, sandbox_provider, form, arch, record)))


def unprobed(env: ComputerEnv, sandbox_provider: SandboxProvider,
form: str | None = None) -> list[tuple[str, str, dict]]:
"""Each container a deploy on ``sandbox_provider`` boots that was built but not probed though the deploy runs it
natively, as (form, arch, record): one whose probe couldn't boot it for a setting that didn't resolve, say. A deploy
composes it again first, which reuses its image and probes it, since a container deploys only once ready."""
composed = env.metadata.get("composed", {})
return [(name, arch, composed[name][arch]) for name, arch in _pairs(
env, booted_forms(env, sandbox_provider, form),
lambda form, arch, record: _unprobed(env, sandbox_provider, form, arch, record))]


def _unprobed(env: ComputerEnv, sandbox_provider: SandboxProvider, form: str, arch: str, record: dict | None) -> bool:
# A disk or VM bundle left unverified deploys already, its first boot probing it (probing.probe_first_boot).
return (form == "container" and record is not None and record.get("status") == "unverified" and not stale(record)
and bool(boot.native_links(sandbox_provider, arch, form, env.stored_id, env.computer.os.type)))


def booted_forms(env: ComputerEnv, sandbox_provider: SandboxProvider, form: str | None = None) -> list[str]:
Expand Down
23 changes: 19 additions & 4 deletions src/agent_env/env/envs/computer/utils/probing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from agentenv_protocol.computer.conformance import run_probe

from agent_env.artifact.artifacts.file import FileArtifact
from agent_env.config.errors import ConfigError
from agent_env.env.env import Env
from agent_env.env.envs.computer.utils import boot
from agent_env.env.envs.computer.utils.composed import Record, failed, now
Expand Down Expand Up @@ -53,16 +54,25 @@ async def probe_first_boot(env: ComputerEnv, form: str, arch: str, sandbox_provi
return result


class Unprobed(Exception):
"""The probe couldn't boot the build, for a setting it boots with that doesn't resolve here: which says nothing of
the build."""


async def checked(links: list[SandboxProvider], env: ComputerEnv, driver: ComputerDriverEnv, form: str, arch: str,
result: dict) -> dict:
"""The build of ``result`` booted on the first of ``links`` that boots it and probed at ``full``: ready, with the
driver's card, or failed at the probe; each with a summary of the probe and its report stored. A probe that ended
on a transport error talking to the driver runs again on a fresh boot first, up to ``_PROBE_ATTEMPTS`` in all, the
record then keeping each attempt; one with a failed check is recorded at once."""
record then keeping each attempt; one with a failed check is recorded at once. A build the probe couldn't boot for
a setting that doesn't resolve here stays as it was, unverified, saying why, so a compose once it resolves probes
it."""
attempts: list[dict] = []
while True:
try:
report, card, provider = await _probe(links, env, driver, result["image"], form)
except Unprobed as e:
return {**result, "reason": f"not probed: {e}"}
except TimeoutError:
outcome = failed(env, form, arch, "probe", result["key"],
f"the probe didn't finish within {_PROBE_TIMEOUT_S} s")
Expand Down Expand Up @@ -141,10 +151,15 @@ async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: Compute
form: str = "container") -> tuple[ProbeReport, dict, str]:
"""``image``, of ``form``, booted at the env's settings and its apps' defaults with a control token of its own, as
a deploy with no ``env_params`` boots it, on the first of ``links`` that boots it, probed at ``full`` with that
token within ``_PROBE_TIMEOUT_S`` and torn down; the report, the driver's card and the provider's name."""
token within ``_PROBE_TIMEOUT_S`` and torn down; the report, the driver's card and the provider's name. Raises
Unprobed, booting nothing, when a setting it boots with doesn't resolve here."""
token = boot.control_token()
environment = {**boot.settings_environment(env.computer, env.computer_values(), environment_name=env.computer_name),
**boot.app_settings_environment(env.composed_apps(), env.app_defaults(), env.computer.os.type)}
try:
environment = {
**boot.settings_environment(env.computer, env.computer_values(), environment_name=env.computer_name),
**boot.app_settings_environment(env.composed_apps(), env.app_defaults(), env.computer.os.type)}
except ConfigError as e: # an env: or secret: reference that doesn't resolve here, or no secret store to resolve it
raise Unprobed(f"a setting it boots with didn't resolve here: {e}") from e
problems = []
for link in links:
try:
Expand Down
13 changes: 11 additions & 2 deletions src/agent_env/providers/env_providers/env_computer_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
discard,
first_native,
listed,
native_links,
ready,
ready_timeout,
recorded_app_settings,
Expand Down Expand Up @@ -87,9 +88,17 @@ async def deploy(
raise DeployRefused(f"{env.id}: env_params.site_routing reaches the run's websites, which a run's "
"deploy_env names in env_params.sites; give them there to deploy it any other way")
await env.compose_unstarted(sandbox_provider, params.form)
if not (builds := env.deployable_builds(params.form)):
builds = env.deployable_builds(params.form)
unprobed = composer.unprobed(env, sandbox_provider, params.form)
# A ready build only of an arch this provider can't run mustn't hide why its native one is still unprobed.
if unprobed and not any(native_links(sandbox_provider, arch, form, env.stored_id, env.computer.os.type)
for form, arch, _ in builds):
builds = []
if not builds:
why = "".join(f"; {name}/{arch}: {record.get('reason', 'not probed')}" for name, arch, record in unprobed)
raise DeployRefused(f"{env.id} has no ready build to deploy; its builds are "
f"{env.build_statuses() or 'none yet'}; compose it with: agent-env env build --id {env.id}")
f"{env.build_statuses() or 'none yet'}{why}; compose it with: agent-env env build --id "
f"{env.id}")
if params.site_routing and not (builds := [build for build in builds if build[0] != "disk"]):
raise DeployRefused(f"{env.id}: env_params.site_routing installs into the computer's container, and a disk "
"computer runs inside a launcher container it can't reach; deploy a container form")
Expand Down
97 changes: 97 additions & 0 deletions tst/unit/env/envs/test_computer_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from agentenv_protocol.computer import COMPUTER_DISPLAY, COMPUTER_READY, COMPUTER_RECORDING
from agentenv_protocol.computer.app import COMPUTER_APP
from agentenv_protocol.computer.app_runtime import HOLD_BOOT_LAUNCH_ENV
from agentenv_protocol.computer.conformance import CheckResult, ProbeReport
from agentenv_protocol.tool_access import DISABLE_TOOL_EXTENSION_URI, ENABLE_TOOL_EXTENSION_URI
from click.testing import CliRunner

Expand Down Expand Up @@ -305,6 +306,43 @@ async def fake_boot(link, **kwargs):
probed.assert_awaited_once_with("http://computer", "full", token=booted[0]["token"])


def test_a_build_the_probe_cant_boot_for_a_setting_that_doesnt_resolve_stays_unverified_until_a_compose_where_it_does(
base, driver, docker, tmp_path, monkeypatch):
monkeypatch.delenv("MAIL_TOKEN", raising=False)
booted = _probing_for_real(monkeypatch)
env = _put(base, driver, apps=[_app(base, driver, tmp_path / "mail", compose=False,
defaults={"token": "env:MAIL_TOKEN"})])
unprobed = Env.get(env.id).metadata["composed"]["container"]["arm64"]
left = composer.pending(Env.get(env.id))
monkeypatch.setenv("MAIL_TOKEN", "t0ken")
composer.compose(Env.get(env.id), left)
probed = Env.get(env.id).metadata["composed"]["container"]["arm64"]

assert (unprobed["status"], unprobed["reason"]) == ("unverified", "not probed: a setting it boots with didn't "
"resolve here: Unresolved env:MAIL_TOKEN reference (no value "
"and no default)")
assert left == [("container", "arm64")]
assert probed["status"] == "ready" and probed["image"] == unprobed["image"]
assert len(booted) == 1 and json.loads(booted[0]["AGENTENV_APP_SETTINGS"])["mail"]["token"] == "t0ken"


def _probing_for_real(monkeypatch) -> list[dict]:
"""Gate 3's own probe, which resolves the settings it boots with, over a boot and a probe that pass: the
environment of each boot it makes."""
booted = []

async def fake_boot(link, **kwargs):
booted.append(kwargs["environment"])
return boot.Booted(sandbox=None, url="http://computer", card=CARD)

monkeypatch.setattr(probing, "_probe", _probe)
monkeypatch.setattr(boot, "boot", fake_boot)
monkeypatch.setattr(boot, "discard", AsyncMock())
monkeypatch.setattr(probing, "run_probe", AsyncMock(return_value=ProbeReport.of("full", "desk", [CheckResult(
check="P0", name="the card", status="passed", extensions=[CARD_EXTENSION])], [CARD_EXTENSION])))
return booted


def test_a_build_whose_probe_fails_or_that_never_gets_ready_fails_at_the_probe_and_isnt_deployed(base, driver, docker,
monkeypatch):
docker.probe_fails["arm64"] = "the click never landed"
Expand Down Expand Up @@ -728,6 +766,65 @@ async def test_a_deploy_composes_again_what_an_agent_env_that_composes_different
assert booted["image"] == record["arm64"]["image"]


@pytest.mark.asyncio
async def test_a_deploy_probes_a_container_left_unprobed_once_its_setting_resolves_and_boots_it_only_once_ready(
base, driver, docker, boots, tmp_path, monkeypatch):
monkeypatch.delenv("MAIL_TOKEN", raising=False)
probed = _probing_for_real(monkeypatch)
env = _put(base, driver, display={"width": 1600, "height": 900}, apps=[_app(
base, driver, tmp_path / "mail", compose=False, defaults={"token": "env:MAIL_TOKEN"})])
unprobed = Env.get(env.id).metadata["composed"]["container"]["arm64"]
monkeypatch.setenv("MAIL_TOKEN", "t0ken")

deployed = await Env.get(env.id).deploy()

build = Env.get(env.id).metadata["composed"]["container"]["arm64"]
(booted,) = boots.boots
assert unprobed["status"] == "unverified" and build["status"] == "ready"
assert build["image"] == unprobed["image"] == booted["image"] == deployed.metadata["computer"]["image"]
assert len(probed) == 1 and json.loads(probed[0]["AGENTENV_APP_SETTINGS"])["mail"]["token"] == "t0ken"


@pytest.mark.asyncio
async def test_a_deploy_refuses_a_container_still_unprobed_for_a_setting_that_doesnt_resolve_saying_why(
base, driver, docker, boots, tmp_path, monkeypatch):
monkeypatch.delenv("MAIL_TOKEN", raising=False)
probed = _probing_for_real(monkeypatch)
env = _put(base, driver, apps=[_app(base, driver, tmp_path / "mail", compose=False,
defaults={"token": "env:MAIL_TOKEN"})])

with pytest.raises(DeployRefused) as refused:
await Env.get(env.id).deploy()

assert str(refused.value) == (
f"{env.id} has no ready build to deploy; its builds are {{'container/amd64': 'unverified', 'container/arm64': "
"'unverified'}; container/arm64: not probed: a setting it boots with didn't resolve here: Unresolved "
f"env:MAIL_TOKEN reference (no value and no default); compose it with: agent-env env build --id {env.id}")
assert probed == [] and boots.boots == []


@pytest.mark.asyncio
async def test_a_deploy_says_why_its_native_container_is_unprobed_though_another_arch_it_cant_run_is_ready(
base, driver, docker, boots, tmp_path, monkeypatch):
monkeypatch.delenv("MAIL_TOKEN", raising=False)
probed = _probing_for_real(monkeypatch)
env = _put(base, driver, apps=[_app(base, driver, tmp_path / "mail", compose=False,
defaults={"token": "env:MAIL_TOKEN"})])
stored = Env.get(env.id)
composed = stored.metadata["composed"]
composed["container"]["amd64"] = {**composed["container"]["amd64"], "status": "ready"}
stored.update_metadata({**stored.metadata, "composed": composed})

with pytest.raises(DeployRefused) as refused:
await Env.get(env.id).deploy()

assert str(refused.value) == (
f"{env.id} has no ready build to deploy; its builds are {{'container/amd64': 'ready', 'container/arm64': "
"'unverified'}; container/arm64: not probed: a setting it boots with didn't resolve here: Unresolved "
f"env:MAIL_TOKEN reference (no value and no default); compose it with: agent-env env build --id {env.id}")
assert probed == [] and boots.boots == []


@pytest.mark.asyncio
async def test_a_reset_ends_the_envs_recordings_then_boots_a_fresh_container_where_the_last_one_ran(
base, driver, docker, boots, monkeypatch):
Expand Down
Loading