From 1e2ae7d1be2734164ac1dffe63f4a29ef6d2c763 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 13 Nov 2026 16:02:47 -0800 Subject: [PATCH] feat(sync_env_clock): an env with no gateway clock, as a computer deployed alone, is refused saying to deploy it in a multi env (FD-5477) Arming an env whose card offers no clock/v1 set_time failed with "env 'desk' does not offer 'set_time' on urn:agentenv:clock/v1". It now says the env has no clock to arm, and that a computer follows a multi env's gateway. Co-Authored-By: Claude Opus 5.5 --- .../task_step/task_steps/sync_env_clock.py | 7 +++++ tst/unit/task_step/sync_env_clock_test.py | 28 +++++++++++-------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/agent_env/task_step/task_steps/sync_env_clock.py b/src/agent_env/task_step/task_steps/sync_env_clock.py index a87eeb173..b97dd9843 100644 --- a/src/agent_env/task_step/task_steps/sync_env_clock.py +++ b/src/agent_env/task_step/task_steps/sync_env_clock.py @@ -3,6 +3,8 @@ Arms the clock (set_time) and reads the gateway's server-reachable env_get_time_url (state), both as the stored env card declares clock/v1, then invokes sync_time on each backing server, MCP server or website, the env card lists (``legacy_protocol.backing_servers``) whose own card advertises it, at the route that card gives. +An env whose card offers no set_time has no gateway clock to arm, as a computer deployed alone, and is refused +saying to deploy it in a multi env. Non-advertisers are skipped when tolerate_missing_sync_time (default True), and websites always. Place right before prompt_agent so the virtual timeline starts at agent-start; when setup needs the clock armed, arm it at rate 0 and start it with set_env_clock_rate right before prompt_agent. @@ -19,6 +21,7 @@ import tzdata # noqa: F401 from agentenv_protocol import client as protocol_v1 +from agent_env.env.env import EnvCapabilityUnsupported from agent_env.env.gateway.constants import EXT_CLOCK_URI from agent_env.task_step.context import StepContextError, TaskStepContext from agent_env.entity.refs import EntityRef @@ -132,6 +135,10 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: timeout=self.timeout_seconds) except httpx.HTTPStatusError as e: raise RuntimeError(f"clock arm failed (HTTP {e.response.status_code}): {e.response.text}") from e + except EnvCapabilityUnsupported as e: + raise RuntimeError(f"env '{self.env_id}' has no clock to arm: its card offers no {EXT_CLOCK_URI} set_time, " + "which a gateway serves. A computer deployed alone follows no clock; deploy it in a " + "multi env, whose gateway it follows") from e state = await deployed.invoke(EXT_CLOCK_URI, "state", timeout=self.timeout_seconds) env_get_time_url = state.get("env_get_time_url") diff --git a/tst/unit/task_step/sync_env_clock_test.py b/tst/unit/task_step/sync_env_clock_test.py index e95acf2b3..952e71ae9 100644 --- a/tst/unit/task_step/sync_env_clock_test.py +++ b/tst/unit/task_step/sync_env_clock_test.py @@ -4,8 +4,8 @@ import pytest -from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, EnvCapabilityUnsupported -from agent_env.env.gateway.constants import EXT_CLOCK_URI +from agent_env.env.env import DeployedEnv, DeployedGatewayEnv, DeployedSandboxEnv +from agent_env.env.gateway.constants import EXT_CLOCK_URI, WELL_KNOWN_PATH from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps import sync_env_clock as mod from agent_env.task_step.task_steps.sync_env_clock import SyncEnvClockTaskStep, resolve_virtual_time @@ -108,15 +108,6 @@ async def test_execute_syncs_each_child_env_through_the_stored_card(monkeypatch) assert [s["service"] for s in context.metadata["clock_configurations"][0]["synced"]] == ["email"] -@pytest.mark.asyncio -async def test_execute_raises_before_any_request_when_the_card_lacks_set_time(monkeypatch): - sent = _mock_http(monkeypatch) - record = _carded({"name": "gw", "capabilities": {"extensions": []}, "children_environments": []}) - with pytest.raises(EnvCapabilityUnsupported, match="does not offer 'set_time' on urn:agentenv:clock/v1"): - await _step().execute(TaskStepContext(deployed_envs=[record])) - assert sent == [] - - @pytest.mark.asyncio async def test_execute_keeps_the_arm_failure_message(monkeypatch): _mock_http(monkeypatch, set_time_status=409) @@ -466,6 +457,21 @@ async def test_a_multi_envs_computer_is_synced_at_its_own_address_with_its_own_c computer.control_headers +@pytest.mark.asyncio +async def test_an_env_with_no_gateway_clock_as_a_computer_deployed_alone_is_refused_saying_to_deploy_it_in_a_multi_env( + monkeypatch): + sent = _mock_http(monkeypatch) + alone = DeployedSandboxEnv(env_id="e1", env_version=1, sandbox_id="sb-desk", control_token="desk-token", + environment_card_url=f"https://desk.example{WELL_KNOWN_PATH}", + environment_card={"name": "desktop", "url": "/agentenv", + "capabilities": {"extensions": [_CLOCK_EXT]}}) + + with pytest.raises(RuntimeError, match="has no clock to arm") as refused: + await _step().execute(TaskStepContext(deployed_envs=[alone])) + + assert "deploy it in a multi env" in str(refused.value) and sent == [] + + def test_a_padded_zone_is_stored_and_sent_stripped(): s = _zoned(timezone=" America/New_York ") assert s.timezone == "America/New_York" and s.to_dict()["timezone"] == "America/New_York"