Skip to content

feat(multi-env): child_envs on deploy_env, each child env's options by its name (FD-4930) - #522

Merged
earakely-scale merged 5 commits into
mainfrom
edgararakelyan/fd-4930-child-envs
Nov 7, 2026
Merged

earakely-scale merged 5 commits into
mainfrom
edgararakelyan/fd-4930-child-envs

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

PR 1 of the four in the FD-4930 + FD-4935 design note, settled Nov 6. A child env of a multi env takes the same settings it takes deployed alone, so a deploy_env step on a multi env takes child_envs: each child env's options by its environment name, as a step deploying that env alone would give them.

{"id": "office", "type": "deploy_env", "env_id": "office", "sandbox_type": "modal",
 "child_envs": {"desktop": {"sandbox_type": "modal", "cpu": 4, "memory_mb": 8192,
                            "env_params": {"form": "container", "site_routing": true}}}}

No child env takes an entry until computers become child envs (FD-4935, PR 3), so this PR is the field and its checks:

  • DeployEnvTaskStep.child_envs, written only when set. preflight and execute pass it to the env, and execute refuses it for an env whose deploy() doesn't take it.
  • MultiEnv.child_envs_refusal, run at save and again at deploy, before anything is deployed. It refuses:
    • a name that isn't one of its child envs, listing the ones it has;
    • an entry that isn't a table of sandbox_type, cpu, memory_mb, disk_size_gb and env_params (the multi env's own step sets the lifetime, gateway and state for every child env);
    • an entry for an MCP server or a website, which run on the multi env's own sandbox.
      Each problem is reported under child_envs.<name>.
  • An env whose deploy() takes no child_envs refuses them at save and at run with the same words, mcp_server envs have no child envs, whatever its provider takes. to_dict() returns a copy of them, so a bundle export, which prunes in place, leaves the loaded step as it was.
  • env_params had the same gap: behind a provider that takes every option, a task with env_params for an env whose deploy() takes none was accepted at save and refused at its run. preflight now checks the env's deploy() for env_params too, and refuses with the words the run raises (mcp_server envs take no env_params). execute asks one helper, _takes, for every optional keyword it forwards.
  • A bundle export drops each entry's sandbox_type, the user's provider, as it drops the step's own. Field paths now take {} for every value of a table, and child_envs{}.sandbox_type is user-scoped.

Also a flaky test this PR's CI hit, fixed here: macos_harness_driver_test.py::test_a_cancelled_call_stops_the_command_it_was_running waits for its fake's pid file to exist and then reads the pid, but the fake wrote it with write_text, which creates the file empty first, so a busy runner could read ''. The fake now writes pid.tmp and renames it into place. Polling the file as the test does, with the CPU saturated, read it empty 16 times in 600 with the old write and 0 in 600 with the new.

Test plan

  • Unit tests in deploy_env_test.py: the round trip; the export dropping each entry's sandbox_type; the refusal for each kind of env with no child envs; each multi env refusal, naming the child env; MultiEnv.deploy refusing before it deploys anything; execute passing child_envs to a deploy that takes them and refusing them for one that doesn't.
  • tst/unit/task_step, env, bundle, serialization and task on macOS: 5,616 passed. The failures are the two test_disk_recipe tests that fail on main on macOS too, and three modules the local venv can't import (hypothesis, openenv).
  • Integration test tst/integration/env/envs/multi_env_child_envs_local_test.py (slow tier), on a real multi env holding an MCP server on the local backend: each entry the multi env refuses (an MCP server's, an unknown name, another key) and env_params on the multi env are refused at save and at the run with the same words, and the local provider makes no sandbox for any of them (a spy on its create calls). Without them, the same env deploys and serves its server's list_items through the gateway's /mcp.
  • Second VM round on an agent-environments-admin VM (c6i.2xlarge, Ubuntu 24.04, us-west-2), at ba6f25d. The VM is torn down.
    • The integration test 3 times in a row: 3 passed (139 s with the first image builds, then 49 s). The existing multi_env_local_test.py passed too. Nothing was left running.
    • CLI e2e: the MCP server's entry, an unknown name, another key and env_params on the multi env are each refused at save, with no container created. The plain task passes.
    • Fuzz: 400 random child_envs and env_params values (non-tables, unknown, empty, long and non-ASCII names, keys of every type, NaN) on a stored multi env. The 341 that set something were refused at save and at the run with the same words, with no other exception and no sandbox made; the 59 that set nothing were accepted. It found that an entry with keys of two types raised TypeError instead of being refused; fixed in ba6f25d, with a unit test.
    • Four runs at once: all passed and tore down. A bundle mixing valid tasks with a refused one is refused at save before any task starts.
    • kill -9 in the middle of a deploy, then a rerun: the rerun passes. The killed run's compose stack is left, as any SIGKILLed run's is. Ctrl-C in the middle of a deploy: the run cancels and tears down; the rerun passes.
    • A Docker daemon restart, then a refused task and the plain task: the same results.
    • The integration test under stress-ng (8 CPU, 4 IO, 2 memory workers): passed in 96 s.
  • Third VM round on an agent-environments-admin VM (c6i.2xlarge, us-west-2), at 8b38654: today's way of running a computer with websites and MCP servers, a multi env deployed and a computer env deployed beside it with env_params.site_routing, through the deploy_env code this PR reworks. The VM is torn down.
    • agent-env run: a multi env holding the echo MCP server and a website declaring its own domain (shop.e2e522.test), and the desktop computer with {"form": "container", "site_routing": true}. The task passes in 54 s: from inside the computer https://shop.e2e522.test/ loads (200, the site's page), the computer's 14 tools answer with a screenshot, and echo answers through the gateway.
    • A website's entry in child_envs is refused at save (child_envs.shop: shop runs on the multi env's own sandbox, …), with no container created.
    • Chaos, on the same deployment made through deploy_env, the site loaded by its name from inside the computer after each: the gateway restarted (echo answers again too); the website's backend and frontend restarted; reset_env on the computer (its screenshot answers too); the computer's container killed (the site can't be reached while it's down), then reset_env, after which the site loads and the screenshot answers again. The teardown leaves nothing.
  • VM e2e on an agent-environments-admin VM (c6i.2xlarge, Ubuntu 24.04, in us-east-2, since us-west-2 EC2 was failing launches), with agent-env run on a bundle whose multi env holds the echo MCP server. The VM is torn down.
    • No child_envs: the multi env deploys in 32 s, the echo verifier passes, and the run tears down its sandbox.
    • An MCP server's entry, an unknown name and an extra key are each refused before anything starts, with no container created:
      • child_envs.echo: echo runs on the multi env's own sandbox, which the step's own fields set, so it takes no entry
      • child_envs.desktop: env '@local/~/bundle/office' has no child env named 'desktop'; its child envs are echo
      • child_envs.echo: an entry is a table of sandbox_type, cpu, memory_mb, disk_size_gb, env_params, not ttl_seconds
    • The first refused run built the echo env's image, since a run registers the bundle's envs before it checks its tasks. A task refused by today's code (env_params on the multi env) builds its env's image the same way, so that's existing behavior.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; the previously reported test cleanup issue remains non-blocking.

Fix All in CursorFindings

  1. P2 Failed reconnect leaves containers running ▶
Fix with agent prompt
### Issue 1
tst/integration/env/envs/multi_env_child_envs_local_test.py:70-72
If `Env.from_instance_id()` raises after deployment, the test never enters its cleanup block. The `local_stack` fixture only removes containers matching `agent-env-local-`, so this Docker Compose stack stays running and keeps using memory and host ports. Put reconnecting inside a cleanup block that can tear down the deployed sandbox even when reconnecting fails.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds child_envs to deployment steps, checks entries before deployment, and removes child provider settings from bundle exports.

  • A deploy step can carry options for child environments by name.
  • Saving a task now catches env_params the environment cannot accept.
  • The fake macOS harness publishes its pid only after the file is complete.

Reviews (12) · Last reviewed commit: "test(drivers): the macos-harness fake wr..." · Reviewed by Greptile

…y its name (FD-4930)

A deploy_env step on a multi env takes child_envs: each child env's options by its
environment name, as a step deploying that env alone would give them (its sandbox
fields and its env_params). The multi env checks them at save and at deploy: an
unknown name, an entry with other keys, and an entry for an MCP server or a website,
which run on the multi env's own sandbox, are refused, each problem under
child_envs.<name>. An env with no child envs refuses child_envs.

A bundle export drops each entry's sandbox_type as it drops the step's own, through a
field path that reaches every value of a table (child_envs{}.sandbox_type).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner November 7, 2026 00:12
… a run does

to_dict() handed out the step's own child_envs, which a bundle export prunes in place,
so exporting stripped each child env's sandbox_type from the loaded step. It now
returns a copy.

preflight() asked the env's provider about child_envs, and a provider that takes every
option passed them, while execute() refused them for an env whose deploy() takes none.
preflight() now checks the env's deploy() as execute() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 2 commits November 6, 2026 16:25
…kes none, as a run does

preflight() asked the env's provider about env_params, and a provider that takes every
option passed them, while execute() refused them for an env whose deploy() takes none:
a task saved and then failed at its run. preflight() now checks the env's deploy() for
env_params as it does for child_envs, with the words execute() raises.

execute() asks _takes() for every optional keyword it forwards, in place of the inline
signature checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a real multi env holding an MCP server, behind the local gateway: each entry the multi
env refuses (an MCP server's, an unknown name, another key) and env_params on the multi
env are refused at save and at the run with the same words, and the local provider makes
no sandbox for any of them; without them the same env deploys and serves its server's
list_items through the gateway's /mcp.

child_envs_refusal names the other keys of an entry as strings, so an entry whose keys are
of two types is refused rather than raising.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +70 to +72
deployed_env = await Env.from_instance_id(deployed.instance_id)
work_dir = deployed_env._sandbox.work_dir
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed reconnect leaves containers running

If Env.from_instance_id() raises after deployment, the test never enters its cleanup block. The local_stack fixture only removes containers matching agent-env-local-, so this Docker Compose stack stays running and keeps using memory and host ports. Put reconnecting inside a cleanup block that can tear down the deployed sandbox even when reconnecting fails.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tst/integration/env/envs/multi_env_child_envs_local_test.py
Line: 70-72

Comment:
**Failed reconnect leaves containers running**

If `Env.from_instance_id()` raises after deployment, the test never enters its cleanup block. The `local_stack` fixture only removes containers matching `agent-env-local-`, so this Docker Compose stack stays running and keeps using memory and host ports. Put reconnecting inside a cleanup block that can tear down the deployed sandbox even when reconnecting fails.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

…elled call's test never reads it empty

test_a_cancelled_call_stops_the_command_it_was_running waits for the fake's pid file to
exist, then reads the pid. The fake wrote it with write_text, which creates the file
empty and then writes it, so on a busy runner the test could read '' (it failed #522's
unit shard with "invalid literal for int() with base 10: ''"). The fake now writes
pid.tmp and renames it into place, so the file exists only with the pid in it.

With the CPU saturated, polling the file as the test does read it empty 16 times in 600
with the old write and 0 in 600 with the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit c6d80ba into main Nov 7, 2026
36 of 37 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-4930-child-envs branch November 7, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant