Skip to content

feat(computer): a computer env composes the apps it lists into its image - #420

Open
earakely-scale wants to merge 12 commits into
mainfrom
edgararakelyan/fd-4367-m13-apps-in-composition-build-mode
Open

earakely-scale wants to merge 12 commits into
mainfrom
edgararakelyan/fd-4367-m13-apps-in-composition-build-mode

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

A computer env now composes the computer app envs it lists. In env.toml:

type = "computer"
computer = "ubuntu-24.04"
driver = "reference"
apps = ["notes", { env = "clock", install = "build" }]

Each app is built into the computer's image after the driver, in the order listed, each under its own folder (/opt/agentenv/apps/<environment name>) with its own venv, programs and server, as an app deployed alone already is. install = "build" is the default and the only value for now; "boot" is refused as not supported yet.

Design: the M1.3 sections of "Computer Envs and Computer App Envs" ("Including an app", "Composition, end to end", "Several apps"). Issue: FD-4367.

How

  • ComputerEnv.apps. put(apps=[...]), from_toml and the wire form carry them; composed_apps() returns them, so the composer, boot readiness and app settings, which already work over that list, take every app. The wire form writes apps only when there are some, so existing computer envs keep their stored form and bundles still reuse them.

  • A toml ref with a key beside it. EntityRef(beside=("install",)) lets an apps entry be { env = "clock", install = "build" }; the resolver accepts install there and still refuses any other key.

  • Gate 1 over several apps. Each app is checked against the base and the driver as before, now including a port the driver listens on (check_app, so an app deployed alone gets it too). The apps are checked against each other: no arch they all run, one listed twice, or two sharing an environment name, a folder, a supervisord program name or a port, each pair named once, for the first thing they'd share. Every problem of one app is named with its index, apps[1]: ....

  • Folders by environment name. An app's folder, its supervisord programs and its conf file take its environment name, which a computer already requires to be unique, instead of its id's last part. Apps whose ids end alike (@a/mail, @b/mail) can share a computer, and a bundle prefix no longer makes a folder's name differ from the app's name. A driver keeps its id's.

  • A part undoing what an earlier one installed. An app's files, venv and programs go under its own folder, so the only steps that can write elsewhere are run scripts (as root) and install steps (as the session user). When two or more parts (the driver or an app) have such steps, the builder saves the built image and lists what each step's layer holds, found from the newest layer down as the layer a shell ran exactly that command for. Only certain losses are refused, failing the pair at its build stage, before the push:

    • a later part deletes a file or folder an earlier one left (a whiteout, or a folder made opaque);
    • it points an earlier part's link elsewhere;
    • it makes an earlier part's file, link or folder another kind;
    • it replaces an earlier part's program (an executable file) with other content.
    failed at build: a part undoes what an earlier one installed: /usr/local/bin/tool: @local/~/kit/notes replaces @local/~/kit/mail's program
    

    Any other file two parts wrote differently may be an edit that kept the earlier part's change (two apps adding users to /etc/passwd, a cache rebuilt from every app's entries), so it's said on a progress line, naming the parts, and not refused. Scratch locations (/tmp, /run, /var/tmp, /var/cache, /var/log, .cache, __pycache__) are never compared. apt packages install in one shared layer that's no part's, so files the package manager owns never count.

    • The rules live in utils/clashes.py (compare over each part's id and what one of its commands wrote, in build order). A path is compared with the last other part to write it, whoever wrote it since, until it's deleted or a loss is recorded on it.
    • Where it runs: Builder.written(tag, commands). This machine's Docker saves the image and reads it in-process (utils/layers_in_image.py, standard library only). A builder VM runs the same script in a container of the image, on the image's own Python, so the VM needs none.
    • With one part or none having such steps, nothing is saved.
  • Image keys cover what an image is made of. The key covered composer.py's source, so every edit to it, a progress line or a check, built every computer env again. It now covers the base, the Dockerfile, the build files with their executable bits, the lock, the form and the arch, and composer.FORMAT (replacing builder.FORMAT), raised only for a change that alters an image some other way, its labels or how it's built. Changing the key builds every computer env once more after this lands, as this PR's changes to the composer would have anyway; later composer edits won't.

Not in this PR

  • Each app's data plane, its child card and /svc/<app>/agentenv through the driver, and the probe running each app's export: M1.4 (FD-4369). Its design doc, CUA Env M1.4: App Data Planes Through the Driver, has seven choices to review.
  • Apps installed at boot (install = "boot").

Testing

Unit: 12,254 passed; the one failure is the macOS-only Hermes pipe test, as on main. Several apps' tests are in a file of their own, test_computer_apps.py; the clash rules are direct tables of clashes.compare. New tests:

  • An image's key changes with a build file's executable bit and with composer.FORMAT.
  • Two apps compose after the driver in their listed order, one of them with an id ending mail in a folder named for its environment name, notes, each with its venv layer and programs; the env stores and reads them back; listing them in the other order makes another key.
  • Gate 1 refusals: listed twice, a shared environment name, a shared folder, a shared program name, a shared port, a port the driver listens on, an arch one app doesn't share, no arch all the apps share. Apps given as an iterator are checked and stored alike.
  • An env folder naming its apps, by bare name and as a table with install; refused: install = "boot", an app listed twice, another key beside the ref.
  • The layer reader on a saved image (a base layer with no history, empty-layer entries, a gzipped layer, files, programs, links, deletions, an opaque folder, setup.sh beside setup.sh.extra, one command run twice, a base layer of the same command), in-process and as the script a VM runs; a command missing or out of order is refused.
  • Certain losses (a deletion, an opaque folder, a link pointed elsewhere, a file made a link, a program replaced) fail both pairs at build with nothing probed, also when the later part first copied the earlier one's file unchanged; a file already deleted before a folder is made opaque isn't counted. Edited files and a rebuilt binary cache are said on one progress line and not refused, more than ten summed up; the same content, scratch paths and files only one part wrote say nothing. One part with run scripts saves nothing. On a builder VM, the comparison runs in a container of the image before any push.

Integration, laptop (local Docker, localhost:5000): computer_app_test gains a computer env desk with two apps, notes (PPA package, download, run script, install step, launched window) and clock (tzdata, a run script, a service on its own port). Through agent-env run, 1 passed in 6 min 20 s: each app runs on its own settings from its own folder, clock answers on its port with tzdata's zones, the card is the desk's, both apps' readiness passes, only notes launches a window, and both arches compared what the two run scripts wrote before the push. The comparison took about 3.5 s on that image (docker save 2.9 s, reading 0.6 s), and the real run-script layers held only each app's own output, so nothing needed leaving out.

Modal VM, emulated: builder_modal_vm_test (slow, remote_sandbox-gated) now also reads what each RUN of its arm64 image wrote, in a container of that image under QEMU in an amd64 Modal VM: jq from the apt layer listed as a program, the driver's venv and installed.json from the next. 1 passed in 7 min 6 s.

Modal VM: the same bundle through bundle register --sandbox modal_vm,local, on S3 and ECR. The desk's amd64 built in a Modal VM, ran its comparison in a container of the image there, and was ready in 2 min 23 s; arm64 built natively on the laptop in 56 s. task run --env-sandbox modal_vm then deployed it on Modal VM, and the check returned the same as on the laptop.

🤖 Generated with Claude Code

earakely-scale and others added 3 commits October 30, 2026 15:55
…at build

A computer env takes apps, computer app envs built into its image after the driver in the order listed. In env.toml
each is a ref, or a table with install = "build" beside it; "boot" is refused as not supported yet. Gate 1 checks each
app against the base and driver, now including a port the driver listens on, and the apps against each other: one
listed twice, two sharing a folder, an environment name or a port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two parts of a composed computer (its driver and apps) can write the same path only from their run scripts and
install steps, the steps that write outside their own folders; the later part's copy would hide the earlier's. When
two or more parts have such steps, the builder saves the built image and lists what each step's layer holds, found by
its command in the image's history; a path two parts wrote with different content fails the pair at its build stage,
before the push, naming the path and the parts. Temporary files, logs, caches and the files installers edit in place
(account databases, the linker's cache, /etc/environment, shell startup files) are left out. A builder VM reads the
layers in a container of the image, on its own Python.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ild compares their files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/env/envs/computer/env.py Resolved
…ps given as an iterator, match each run's layer exactly

- Gate 1 refuses apps that each share an arch with the base and driver but share none all together, and two apps
  whose supervisord programs would take one name (an app mail with a service sync, and an app mail-sync).
  gate.programs names an app's programs, and the composer writes them by it.
- ComputerEnv.put lists its apps once, so an iterator is checked and stored alike.
- The layer reader finds each RUN's layer by the exact command a shell ran, the next one after the last found, so
  setup.sh no longer matches setup.sh.extra and a command run twice maps to two layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 2 commits October 30, 2026 18:36
…s layer of the same command never stands in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iles two parts wrote differently

The layer reader lists each entry's kind: a file, a program (a file any can run), a link, a folder, a deletion or a
folder made opaque. A build fails only when a later part certainly undoes what an earlier one installed: it deletes
it, points its link elsewhere, makes it another kind, or replaces its program. Any other file two parts wrote
differently may be an edit that kept the earlier part's change, such as two apps adding users or a cache rebuilt from
every app's entries, so a progress line names it and the parts, and the build goes on. Only the scratch locations of
the filesystem hierarchy standard are never compared; the list of files edited in place is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 4 commits October 30, 2026 19:40
…ser's source

An image's key covered composer.py's source, so any edit to it, a progress line or a check, built every computer env
again. The key now covers the base, the Dockerfile, the build files with their executable bits (the composer alone
sets them), the lock, the form and the arch, and composer.FORMAT, which replaces builder.FORMAT and is raised only for
a change that alters what an image holds some other way, its labels or how it's built.

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

An app was installed in a folder named for its id's last part, so two apps whose ids end alike (@a/mail, @b/mail)
couldn't share a computer, and a bundle prefix made a folder's name differ from the name everything else uses. Its
folder under /opt/agentenv/apps, its supervisord programs and its conf file now take its environment name, which a
computer already requires to be unique; a driver keeps its id's. Gate 1 names each pair of clashing apps once, for
the first thing they'd share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of an image it emulates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…write; skip files already deleted under an opaque folder

A path's owner passed to whichever part wrote it last, so a part that first copied identical bytes over an earlier
part's program could then delete or replace it unchecked. Every part that wrote a path is now kept, and each later
command is checked against the last other part to write it. An opaque folder no longer counts files its earlier part
had already deleted, and marks what it hides as deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +515 to +518
entries[path] = entry
writers.setdefault(path, [])
if of not in writers[path]:
writers[path].append(of)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Deleted files block valid builds

writers[path] keeps earlier apps even after their file is deleted. If app A creates and deletes a file, app B can recreate it successfully. But when B later deletes or replaces its own program, _other still returns A. _lost then reports that B broke A's file and rejects a valid build.

Clear the old writers when a path is deleted, including when an opaque folder hides it, while preserving checks against losses already recorded.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/env/envs/computer/utils/composer.py
Line: 515-518

Comment:
**Deleted files block valid builds**

`writers[path]` keeps earlier apps even after their file is deleted. If app A creates and deletes a file, app B can recreate it successfully. But when B later deletes or replaces its own program, `_other` still returns A. `_lost` then reports that B broke A's file and rejects a valid build.

Clear the old writers when a path is deleted, including when an opaque folder hides it, while preserving checks against losses already recorded.

---

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4a100a3: a path's earlier writers are forgotten once it's deleted (an opaque folder's hidden paths too) or once a loss is recorded on it, so the next write starts it afresh. The rules moved to utils/clashes.py in the same commit. Test: test_what_isnt_certainly_lost_isnt_a_loss_and_only_a_file_two_parts_wrote_differently_is_said [recreated]: a creates and deletes /t, b recreates and deletes its own /t, no loss; [recreated-after-opaque] the same through an opaque folder. [after-a-loss] in the loss table checks a third part is then compared with the part whose write was recorded.

earakely-scale and others added 2 commits October 31, 2026 00:17
…ter a deletion starts the path afresh

- utils/clashes.py holds what the composer refuses or says about two parts' writes: the scratch paths, each layer
  entry parsed once into an Entry, and compare() over (part, what one command wrote) in build order. The composer's
  _compare reads the layers and words the result, so _built reads build, compare, push, probe.
- A deleted path, an opaque folder's hidden one, or one a loss was recorded on forgets its earlier writers, so a part
  that recreates and then deletes its own file is no longer blamed for an earlier part's.
- _Layer.of is env_id. ComputerEnv.from_toml reads its apps in _listed_apps, which shares from_dict's type check.
- Several apps' tests move to test_computer_apps.py, the clash rules as direct tables of compare(); the shared mail app
  helper and its constants move to _computer.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an image of the same key an earlier run pushed

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