Skip to content

feat(computer): a run composes only the forms its sandbox boots - #461

Merged
earakely-scale merged 36 commits into
mainfrom
edgararakelyan/fd-5094-compose-only-the-forms-a-runs-sandbox-will-boot
Nov 3, 2026
Merged

earakely-scale merged 36 commits into
mainfrom
edgararakelyan/fd-5094-compose-only-the-forms-a-runs-sandbox-will-boot

Conversation

@earakely-scale

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

Copy link
Copy Markdown
Collaborator

Was stacked on #456 (M4.1, the disk form). #456 has merged, and this PR is now based on main. Merge commit 652e588 brings in main: its tree is main plus this PR's two commits, replayed without conflicts, so the diff is FD-5094 alone.

Linear: FD-5094. Decided by Edgar: compose per run, not by an env-level forms list, since the same env needs disks on a disk-booting sandbox and not on local Docker.

Why

Since #456, every compose of an ubuntu-24.04 computer env also makes each arch's disk: about 15–25 s more per arch on Linux, 1.5–3 min on a Mac, and about 1.6 GB per disk. A run on local Docker never boots them, because a deploy takes the first form its sandbox runs natively, and containers come first.

What changes

  • A run composes only the forms its sandbox's deploys boot.
    • agent-env run (and prepare_bundle, dry_run_bundle) composes with Composing.for_run(sandbox_provider, emulate) (forms_of="deploys"). Each compose looks the provider up once, when it needs it (Composing.provider()), so a bundle with no computer env never builds a default sandbox provider it may not load.
    • composer.booted_forms picks the forms: on each link of the sandbox's chain, the form of the first pair that link boots natively, by boot.first_native, the rule the deploy picks its build by too; a chain gets the union of its links' forms.
    • Local Docker therefore composes containers, with or without KVM.
    • A sandbox that boots only disks (none in this repository yet; Modal VMs in M4.2) composes disks. compose itself adds the container each disk pair is made from, unless it's built, so a disk is never skipped for want of a container no one asked for.
    • A reused env's pending pairs are filtered to those forms the same way.
  • A compose with no target still builds every form: a put, env build, bundle register and the release.
  • Keys don't change, so a later compose that needs a missing form builds it then and reuses everything else. A composed computer records only the forms built, as before: it gets a disk form only when every arch it has has a disk.
  • A deploy that boots a form no compose started composes it first, with no error (ComputerEnv.compose_unstarted, called by the computer env provider before it picks a build).
    • This covers a disk its env_params name, or a disk-only sandbox after a local run.
    • It composes on the deploy's sandbox provider, and gate 3 probes what it builds there.
    • Deploys of one env at once compose it once: the others wait on a file lock, read the result again and boot it. A deploy that finds a pair it boots building waits the same way.
    • It doesn't retry failed or skipped pairs of the forms it boots; those stay with env build and the next run's pending. The container a disk is made from is the exception: a compose of the disk builds it again when it isn't built.
    • A cancelled deploy holds the lock until its compose's thread ends, so the next deploy waits for what it builds.
    • Where a local run's fence would refuse the env's record (Config.local_run_refuses_write, the rule check_local_run_write raises by), it composes nothing and the deploy refuses, naming agent-env env build --id <env>.
    • In agent-env run the deploy's compose shows in the run's output. The run sets its progress for each task in a ContextVar (agent_env.utils.progress), which the task's step tasks and asyncio.to_thread carry to the deploy. The deploy says why it composes, then the compose's stage lines; a deploy waiting for another says what it waits for and since when, every HEARTBEAT_S with the time waited, then what it reuses. Outside a run the lines go to the log.
  • No public signature changes: Composing gains a field with a default and for_run, pending an optional argument, awaiting_lock an optional waiting callback, and Config local_run_refuses_write.

What a run's deploy of the disk prints, from the KVM box (the run itself composed the containers; arm64 has no emulation there):

[tasks/probe.json] step 1/4 env (deploy_env)
[tasks/probe.json] @local/agentenv-framework/desktop/desktop: composing disk/amd64, disk/arm64 before this deploy (no compose built them for LocalSandboxProvider yet); later deploys reuse them
[tasks/probe.json] @local/agentenv-framework/desktop/desktop: composing container/arm64, disk/amd64, disk/arm64 on LocalSandboxProvider
[tasks/probe.json] @local/agentenv-framework/desktop/desktop: container/arm64: failed at resolve: this machine can't build linux/arm64: its Docker has no emulation for it (0 s)
[tasks/probe.json] @local/agentenv-framework/desktop/desktop: disk/arm64: skipped: its container form didn't build (failed) (0 s)
[tasks/probe.json] @local/agentenv-framework/desktop/desktop: disk/amd64: reusing the disk of the same key, made earlier
[tasks/probe.json] @local/agentenv-framework/desktop/desktop: disk/amd64: probing on LocalSandboxProvider
[tasks/probe.json] @local/agentenv-framework/desktop/desktop: disk/amd64: reused, ready (21 s)
[tasks/probe.json] step 1/4 env done in 28.6s

A second deploy that waits says <env>: waiting for another deploy composing disk/amd64, disk/arm64 (building since HH:MM UTC), then <env>: reusing what another deploy composed: disk/amd64 unverified, disk/arm64 ready.

Tests

  • Unit:
    • The forms chosen for local Docker (with and without KVM), a deploy that names the disk, a disk-only sandbox, chains, and a sandbox that boots nothing natively.
    • A put composes every form, and a run's compose only containers, with no disk form in the computer.
    • Three concurrent deploys of the disk an env never composed make each disk once, reuse the containers, and all boot it.
    • A deploy that starts while another deploy's compose has the disk building waits for it and boots it.
    • A run's dry run now says it would compose the containers only.
    • What a run composes is what each link of its sandbox then boots, for local Docker, a disk-only sandbox and a chain; a compose of disks alone builds their containers.
    • The run's progress reaches a deploy's compose through Task.run, with the reason line and the stage lines; a waiting deploy's waiting and reused lines; a cancelled deploy holding the lock; the lock's waiting callback and its repeats.
    • local_run_refuses_write against real store setups, and the deploy both ways: composes where the fence allows the record, refuses with the env build --id hint where it wouldn't.
  • Integration (int_test_slow + kvm): disk_boot_test.py first runs the desktop deploying its container, which composes no disk. Then it runs the disk probe task, whose deploy composes the disk (reusing this arch's container record unchanged), probes it at full under KVM, boots it, and resets it; the run's output carries the reason line and the disk's stage lines.

Results

At 24115a5, on a fresh EC2 c8i.4xlarge with nested KVM:

  • make unit-test: 12,692 passed and 7 skipped.
  • make int-test-fast: 179 passed.
  • disk_test.py: 3 passed.
  • The slow KVM test passed, in the 427 s the two disk files took together. Its local run composed no disk; then the disk task's deploy composed the disk with this arch's container record unchanged, probed it at full (ready), booted it, probed it, reset it and probed it again.

At 13391a5, Greptile's fix, on a second fresh box: make unit-test 12,693 passed and 7 skipped, and the slow KVM test passed again, in 345 s. The new waiting test fails on 24115a5.

At 3fc487f (the progress lines and the six maintainability fixes), on a third fresh box: make unit-test 12,762 passed and 7 skipped, make int-test-fast 180 passed, and disk_test.py with the slow KVM test 4 passed. At bb700ea (Greptile's cancel and --id fixes), the touched unit files, 305 tests, passed on the same box, and the example above is its agent-env run. At ac9ee5d (the lazy lookup), on a fourth fresh box: make unit-test 12,764 passed and 7 skipped, and the slow KVM test passed.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge.

Summary

Runs compose only the computer forms their sandbox boots. Deploys build missing forms before booting and share that work across concurrent deploys.

  • Bundle runs build the computer forms their sandbox deploys can boot.
  • A deploy composes a needed computer form before it boots.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Run[Prepare a run] --> Computer{Computer env needs composing?}
  Computer -->|No| Continue[Continue without loading default provider]
  Computer -->|Yes| Provider[Use selected or default provider]
  Provider --> Forms[Compose forms its deploys boot]
  Forms --> Deploy[Deploy]
  Deploy --> Missing{Required form never started?}
  Missing -->|Yes| Lock[Take lock and compose missing form]
  Missing -->|No| Boot[Boot available build]
  Lock --> Boot
Loading

Reviews (9) · Last reviewed commit: "fix(computer): a run's compose looks up ..." · Reviewed by Greptile

earakely-scale and others added 23 commits November 2, 2026 16:20
The ubuntu-24.04 base gets a disk form: a GPT disk of an ESP holding one UKI (Ubuntu's kernel and a busybox
initramfs) and an ext4 root that is the container form's root filesystem plus /etc/agentenv/vm/image.env. Its init
reads a NoCloud seed and switch_roots into the image's entrypoint; UEFI only.

Composing builds each arch's disk after its container with the disk tools (mkfs.ext4 -d, unprivileged, on the
builder's own platform), keeping the base disk's ESP, keyed by the container's key, the base disk and the recipe.
Gate 3 probes a disk where a provider boots disks natively; elsewhere it stays unverified and a deploy's first boot
with KVM probes it. A deploy naming form = "disk" boots it through create_vm(image=..., env=...): on local Docker,
the QEMU launcher with /dev/kvm. The release publishes the disk tools, the launcher and the base's disk by a digest
over its sources, and boots the disk before pushing it.

FD-4363 (M4.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kout's, its base's and tools' included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… resolve with no bind mount

Under colima with Rosetta, buildx inspect lists only linux/arm64 and linux/386, yet linux/amd64
containers run, so the composer refused amd64; it now runs a container of the base (or the image
the shipped bases build from) for the platform, once per process. colima shares only $HOME and the
composer's work folder is in $TMPDIR, so the resolver's bind mounts failed; its inputs and outputs
now go through docker cp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cancelled boot torn down, no enlarging, trailing newlines kept

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… says so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de them; the platform check signs in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter missing one arch's disk still has

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o another arch's disk boots under TCG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…isk form

_probe keeps the disk form's `form` and boots with the apps' defaults as #453 made it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… probe

A file lock per env, form, arch and image (holding_locks, so across processes too) lets the first
deploy probe and record; the others wait, read the recorded result, and boot a ready disk or fail
with the recorded reason without probing again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ariables base64'd on the way in

The resolver's container is created, filled, run and emptied by one deadline, so a stalled pull
raises ResolveError at the requested timeout; the container, named up front, is removed whatever
happened. The local provider hands the launcher each VM_ENV_ value base64'd, so seed copies them
as they are: a value of several lines reaches the computer whole, and a line in one that looks
like another variable makes none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rds matched by key, the disk layout's numbers named

Waiting deploys poll the file lock (awaiting_lock) instead of holding a worker thread each, which the
probing deploy needs to finish; a waiter takes a failed record, which keeps the build's key but no
image; the resolver deadline test's cleanup no longer moves its clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The udev rule and trigger raced on two CI shards, which moved to `chmod 666 /dev/kvm`; the release's
disk-build used the same udev step, and a failed disk-build blocks the PyPI publish behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The published base disk's PublishedImage carries SIZE_GB from the base's artifact.toml as a build
arg, in its tag and its plan entry, which infra_build check holds to the base's min_disk_gb; the boot
Dockerfile and the disk tool have no default, so a new disk without a size is refused. Tags are
made by source_tag rather than by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… base's Dockerfile pins

Read from that Dockerfile's FROM, so a Dependabot bump of the base moves it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ames it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s own reader

The disk form's checks get the container's "only {served}" detail, and get_config is imported at the
module's top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reuse and failure handling

_key, _stored and _attempting (one context manager mapping a build's failures to its result), so the
disk build gets the "exec format error" reason too; making the base's disk is a helper of its own.
Container keys are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…preconditions beside deployable_builds

probing.py holds the probe a compose runs and the first-boot probe a deploy of an
unverified disk runs, so the computer env provider no longer imports the composer;
composed.py holds the composed record they both write. The env's values of its
base's settings are ComputerEnv.computer_values, and the refusal of a disk larger
than its base makes it is ComputerEnv.disk_size_refusal, next to the statuses a
deploy may boot. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ke boots in the launcher it's given

The sign-in, registry lookup and step output that infra-build, agent-build and
disk-build each ran as a copied shell block are `infra_build.py wanted`; the
build, with its labels and, for a disk form, its base's image of the arch, that
image's config, the disk tools and its size, is `infra_build.py build`, so
disk-build now passes SIZE_GB from the plan; and the tagging the three publish
jobs ran is `infra_build.py publish`. The script stays standard library only,
and each does what its shell block did.

`smoke` takes the launcher to boot a disk form in as --launcher, and waits for
every program its base's supervisord.conf declares rather than three named ones.

disk-publish is now infra-publish on the disks' list, and disk-build is
infra-build but for its launcher's steps, which the tests check as they did the
agents' jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s build args

_checkout rebuilt each PublishedImage from its name, Dockerfile and args, so the
disk image's copy lost SIZE_GB and its tag never matched the installed one's. It
now replaces only the Dockerfile and the images it's built from, keeping every
other field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run's compose (Composing.booted_only) builds only the forms its deploys on its
sandbox boot (booted_forms): on each link of a chain, the first of the base's
forms it boots natively, as a deploy picks, so local Docker composes containers
and a sandbox that boots only disks composes disks with the containers they're
made from. A put, env build and the release still compose every form. Keys are
unchanged, so whatever a later compose needs it builds then, reusing the rest.

A deploy that boots a form no compose started, a disk its env_params name
included, composes it first (ComputerEnv.compose_unstarted); deploys of one env
at once compose it once, under a file lock.

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 3, 2026 07:07
Comment thread src/agent_env/env/envs/computer/env.py Resolved
…mpose

A deploy that loaded the env after another deploy's compose had recorded its pairs
as building found nothing unstarted, skipped the lock and refused the build it
then found unready. It now takes the lock whenever a pair it boots is unstarted
or building, reads the record again once it holds it, and boots what the other
compose built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from edgararakelyan/fd-4363-m41-disk-form-pr to main November 3, 2026 13:29
#456 landed on main as one squash commit, so this branch's copies of its commits conflicted with it. The tree is
main plus this PR's two commits, replayed on main without conflicts (8b4e5e34).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/env/envs/computer/env.py Resolved
…o compose it

A deploy doesn't retry a failed build (a run and `env build` do), so its refusal now ends with the command that
composes the env again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 9 commits November 3, 2026 06:03
… form at deploy

Composing records the env, a write a local run's fence refuses for an env the configured stores hold, so such a
deploy failed on the fence instead of refusing for want of a ready build as before; it now refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al one it puts

A bare-id env can't name the fixtures' @Local computer and driver, so the test reports the env as held by the
configured stores, in a local run, to the deploy's own checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and what it waits for

agent-env run sets its progress for each task's run in a ContextVar
(agent_env.task.progress), which the task's step tasks and asyncio.to_thread
carry to the deploy, so a deploy's compose prints in the run's output instead
of only the log:

- one line first, saying what it composes before this deploy and why, and that
  later deploys reuse it;
- the compose's own stage lines and heartbeats, after the env's id;
- for a deploy waiting for another's compose, what it waits for and since when,
  again every HEARTBEAT_S with the time waited, then what it reuses.

awaiting_lock takes an optional waiting callback, awaited once it finds the lock
held and every every_s after. Outside a run the lines go to the log, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… computer env imports without a cycle

Importing agent_env.task.progress ran the task package's __init__, which reaches
the bundle code that imports the computer env back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te, local_run_refuses_write

check_local_run_write raises where it's true, so code that would write in a run, as
a deploy that composes does, asks the same rule instead of copying half of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er, and a disk's container added by compose

- boot.first_native picks the first (form, arch), in the base's order, a link
  boots natively; the deploy picks its build with it and booted_forms the forms
  a run composes, so the two can't drift.
- pending, unstarted and unstarted_or_building are one pair helper with named
  predicates, over ComputerEnv.composed_arches.
- compose adds the container each disk pair is made from unless it's built,
  instead of booted_forms adding the container form beside a disk.
- Composing.for_run(sandbox_provider, emulate) replaces the booted_only flag,
  which no release had: it resolves the default provider once and sets
  deploys_on.
- compose_unstarted asks Config.local_run_refuses_write, the fence's own rule,
  and its docstring, booted_forms' and unstarted's are cut to the why; the rules
  are in computers/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… local namespace's own stores

The default document store is documents.db, beside the namespace's local.db, so it
counts as shared; the case the fence allows is the one whose configured stores
are the namespace's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mpose's thread ends

A cancel ended the deploy's wait on the compose thread, which runs on, and released
the lock, so the next deploy found the pairs still building and refused. The deploy
now waits for the thread to end before the cancel goes on; a second cancel still
stops the wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a computer env needs it

Composing.for_run resolved the default provider as the run began, so a bundle with
no computer env failed before planning when that default couldn't load. It now
records only that it's a run's (forms_of="deploys"); each compose resolves the
provider once, when it needs it (Composing.provider()), and hands it to forms().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit ebb3114 into main Nov 3, 2026
26 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-5094-compose-only-the-forms-a-runs-sandbox-will-boot branch November 3, 2026 16:14
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