Skip to content

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

Open
earakely-scale wants to merge 5 commits into
mainfrom
edgararakelyan/fd-4930-child-envs
Open

earakely-scale wants to merge 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.
  • 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, with one non-blocking cleanup issue remaining in the integration test.

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 and checks each entry before deployment.

  • Deploy steps can carry options by child environment name.
  • Tasks now catch unsupported env_params before they run.
  • Bundle exports leave child provider choices to the receiving user.
  • The macOS harness writes its PID file atomically.

No new issues were found. The earlier export-copy and save-check findings are fixed.

Reviews (10) · 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>

This branch has not been deployed

No deployments
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