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 aacdc5c40..6f1ed4c3c 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; a record that kept no card is refused saying so. 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,13 @@ 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: + if not deployed.environment_card or not deployed.environment_url: + raise StepContextError(f"env '{self.env_id}': its deploy record keeps no env card or card URL, so " + "its clock can't be reached; deploy it again") from e + raise StepContextError(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 e8d01e65c..8c6d57b93 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 StepContextError, 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,31 @@ 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(StepContextError, 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 == [] + + +@pytest.mark.asyncio +async def test_a_record_that_kept_no_card_is_refused_saying_so_not_told_to_deploy_a_multi_env(monkeypatch): + sent = _mock_http(monkeypatch) + + with pytest.raises(StepContextError, match="its deploy record keeps no env card") as refused: + await _step().execute(TaskStepContext(deployed_envs=[_cardless()])) + + assert "multi env" not 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"