Skip to content

feat(bundle): import a stored task or eval as a bundle folder, with its references pinned - #219

Open
earakely-scale wants to merge 3 commits into
mainfrom
feat/bundle-import
Open

earakely-scale wants to merge 3 commits into
mainfrom
feat/bundle-import

Conversation

@earakely-scale

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

Copy link
Copy Markdown
Collaborator

Summary

A new bundle group with one command, bundle init, which writes a task that a store holds, or an eval and its tasks, as a bundle folder that agent-env run runs. You can then edit the folder and run it locally.

$ agent-env bundle init ./rocket --from-task task-rocket-prospecting-christina-rivera-v1 --version 7
Imported task task-rocket-prospecting-christina-rivera-v1 v7 into ./rocket:
  README.md
  tasks/task-rocket-prospecting-christina-rivera-v1.json
Pinned:
  env rocket-mortgage-env v2: tasks/…json: step '…-deploy-env': env_id
  artifact rocket-mortgages v2: tasks/…json: step '…-load': artifacts[0].id
  agent claude-code-cli v65: tasks/…json: step '…-deploy-agent': a2a_agent_id
Floating:
  agent claude-code-cli: tasks/…json: step '…-verify': judge_a2a_agent_id: the field takes no version, so a run reads the latest
Run it with: agent-env run ./rocket

That's the real output on the dev registry, with the long paths shortened.

What it writes

  • tasks/<name>.json: the stored steps, read through the current step models. This is exactly what task get prints, with no defaults stripped.
    • Pins: every store reference is pinned at the version the store holds now, so the folder keeps running what the store ran. A pin the task already had is kept.
    • Unpinned: an id that a step of the task writes, since the run writes it again and the bundle names it under the task.
      An @local one, from a task a bundle wrote, takes its last segment, numbered -2, -3 and so on when another output or a store reference of its kind has that name. The steps that read it follow.
    • Floating: a reference whose field takes no version (an env one only when no step of the task deploys that env), and a step that names no agent and so gets the configured default. Both are reported.
    • Existing pins: a pin the task already had is read at its version, and is a problem if the store doesn't hold it.
  • Secrets: an env-var entry (env_vars, default_env_vars) is dropped when its name looks like a secret, using config's existing secret-key check, or when its value holds a URL's credentials. A URL's credentials anywhere else are masked. A user name like git isn't treated as a credential.
  • --from-eval: writes each task the eval names, plus evals/<name>.toml listing them. A task the eval names twice is written once.
  • Names: each folder takes the last / segment of its id. A character an id can't hold becomes -; a name with no letter or number left falls back to task; caseless clashes get -2, -3 and so on.
  • README.md: its first line, which is the bundle's description, says what was imported. Below it are what was pinned, what floats and what was left out.

Safety

  • Store: it only reads; nothing is written to a store.
  • Destination: it must be new or empty, its parent must exist, and its path must give a valid bundle id. All of that is checked before the store is read.
  • Failures: the folder is written beside its destination, checked the way agent-env run checks a bundle before it reads a store, then moved into place. A failed or interrupted import (Ctrl-C or SIGTERM) leaves nothing behind, and an empty destination is put back if the move fails or is interrupted.
  • One-line errors: a folder it can't write in, a full disk, a failed move, and a destination that something else wrote during the import (such as a second import of the same folder) each give one line, not a traceback.
  • Every problem at once: a reference the store doesn't hold is reported with all the others, and nothing is written.

Small changes to existing code, so the importer reuses them

  • bundle.parse: the id-root derivation moves into _id_root, which the parser still uses. A new root_problems(root) checks a path that doesn't exist yet. _refused becomes the public segment_problem.
  • bundle.plan: _GETTERS becomes STORE_GETTERS.
  • config.describe: _is_secret_key becomes is_secret_key.
  • bundle.resolve: the steps a step runs after are now worked out only for a step that reads an output the task writes, and in linear time. Before, they were worked out for every step, in cubic time when steps name no depends_on. A 3,000-step task took ~400 s to resolve, in run and check as much as in an import, and now takes under a second.
  • cli.utils: interruptible(), which turns SIGTERM into Ctrl-C, moves here from cli._plugin_changes, so bundle init shares it.
  • bundle/_toml.py: a small TOML writer, since tomllib only reads. It's checked by a tomllib round trip; this PR uses it for eval tomls, and the follow-up for env and agent tomls.

Tests

  • Unit, importer_test.py (36 tests), against a seeded local store holding bare ids:

    • the stored steps, written with their pins;
    • outputs left unpinned;
    • floats, including default agents;
    • secrets dropped and URLs masked;
    • the README;
    • the round trip: materializing the folder writes the stored steps, apart from the pins and output ids named under the task;
    • evals, names, and the destination rules;
    • refused references, failures that leave nothing behind, and steps whose type declares no references;
    • a destination written during the import, a folder it can't write in, SIGTERM, and an interrupted move;
    • renamed outputs, numbered when their name is taken;
    • the CLI's output, usage errors and one-line errors;
    • no store writes.

    toml_writer_test.py covers round trips, layout, and values TOML can't hold. resolve_test.py checks the new upstream walk against the plain transitive walk on 500 random step graphs (cycles and later steps included), and resolves a 3,000-step task under a 60 s timeout.

  • Integration, fast tier (bundle_init_test.py), a hello-shaped task stored under bare ids:

    • it imports, agent-env runs, and reruns writing nothing;
    • it still passes after a breaking new version of its artifact is put in the store, because the pin holds;
    • an imported eval runs;
    • a failed import leaves nothing beside the destination.
  • Integration, slow tier (run_bundle_built_envs_test.py): an MCP server env, its check file and task are stored under bare ids, and the task imports and runs through the gateway twice; the second run writes nothing.

  • Unit tier: 8,150 passed. ruff check passes, and the plugin-API check reports no break.

  • Live, dev registry:

    • rocket v7 imports in 5 s, and run --dry-run --sandbox beta_scale accepts the folder.
    • Materializing it and diffing the @local task against registry v7 step by step and field by field, with a scratch script that isn't shipped, shows 5 of 5 steps equal. The only differences are the three pins the import added: env_version 2, artifacts[0].version 2 and a2a_agent_version 65.

Chaos testing

Run on two EC2 VMs, a t3.large (amd64) and a t4g.large (arm64), both Ubuntu 24.04, each with its own seeded local store. The full battery ran on eddda2c. The scenarios the last commit touches (s01–s04, s08) ran again on 5a3c2bf, where s03 also imports into an empty folder in half its trials.

Scenario amd64 arm64
s01/s02: import, run, rerun, an eval; the pin holds after the store moves on, and a new import pins the new version pass pass
s03: a 3,000-step import 2.3 s (394 s before) 2.2 s (405 s before)
s03: SIGINT, SIGTERM and SIGKILL, 30 each, late in the import, half into an empty folder: never a partial destination, an empty folder never lost, and a retry always works pass pass
s04: 8 concurrent imports into one new folder, and into one empty folder: one wins, the rest give one line pass pass
s05: a full disk (a 256 KiB tmpfs): one line, nothing left pass pass
s06: a read-only parent: one line pass pass
s07: non-empty, a file, a symlink, a missing parent, ids a folder can't give; ~ and relative paths pass pass
s08: 11 odd ids (#, /, Unicode, zero-width, 300 chars, caseless clashes) import under valid names, and all run pass pass
s09: dangling references: all named, nothing written pass pass
s10: secrets never reach the folder or the output pass pass
s11: a step type no plugin registers: one line pass pass
s13: the 3,000-step import's RSS (130 MB) and its dry run pass pass

It found three problems, all fixed here:

  • the cubic resolve walk;
  • a traceback when the parent can't be written, the disk is full, or a concurrent import lands first;
  • staging left behind on SIGTERM.

Still expected:

  • SIGKILL can't be caught, so it can leave a hidden .<name>.import-<hex> folder beside the destination (2–8 of 30 trials). The destination itself is never partial, and a retry works.
  • s12: a corrupt local sqlite store prints a traceback, but task get does too, so that's the store's error handling and not this PR's.

Not in this PR

--with env,agent,data comes in the follow-up PR: it brings envs, agents and data into the folder from build contexts and downloads.

This is not hot path: it's the CLI and bundle code that only the CLI runs, so there's no release of its own.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with one remaining non-blocking interrupt edge case.

Fix All in CursorFindings

  1. P2 Interrupted imports delete empty folders ▶
Fix with agent prompt
### Issue 1
src/agent_env/bundle/importer.py:228-232
The new `SIGTERM` handler raises `KeyboardInterrupt` throughout the import, but `_move` restores an empty destination only after `OSError`. If the signal arrives after `self.dest.rmdir()` and before `os.replace`, the destination stays deleted and `write` removes the staged bundle.

Defer interrupts across the move or restore the destination when an interrupt stops it.

---

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

Summary

Adds agent-env bundle init to turn a stored task or eval into an editable bundle folder.

  • Stored tasks and evals can become runnable bundle folders.
  • Bundle checks find step dependencies only when they need them.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Check destination] --> B[Read task or eval]
  B --> C[Rename outputs and pin references]
  C --> D[Drop likely secrets]
  D --> E[Write sibling staging folder]
  E --> F[Check bundle]
  F --> G[Move into destination]
Loading

Reviews (3) · Last reviewed commit: "fix(bundle): number a renamed output who..." · Reviewed by Greptile

…ts references pinned

agent-env bundle init <folder> --from-task ID [--version N] writes a task a store holds as a bundle that
agent-env run runs: tasks/<name>.json is the stored steps read through the current step models, with every
store reference pinned at the version the store holds now, so the folder keeps running what the store ran.
An id a step of the task writes stays unpinned, since the run writes it again. --from-eval writes an eval's
tasks and evals/<name>.toml.

Values that look like secrets are left out: env-var entries whose names look secret or whose values hold a
URL's credentials are dropped, and a URL's credentials elsewhere are masked. The README records what was
imported, pinned, floating and left out. Nothing is written to a store; the folder is written beside its
destination, checked as agent-env run checks a bundle, and moved into place, so a failed import leaves
nothing.

The parser's id-root derivation and segment check, the planner's store getters and config's secret-key
predicate are now public, so the importer reuses them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 15, 2026 02:51
Comment thread src/agent_env/bundle/importer.py Outdated
Comment thread src/agent_env/bundle/importer.py
Comment thread src/agent_env/bundle/importer.py Outdated
Comment thread src/agent_env/bundle/importer.py Outdated
…e errors when the write fails

- An @Local output the stored task writes is renamed to its last segment, and the steps reading it follow.
- `bundle init .` takes the folder's absolute path.
- An env ref without a version floats unless the task deploys it; an existing pin is read at its version.
- A step's upstream steps are worked out on demand and in linear time, rather than for every step up front
  in cubic time; a 3,000-step task took ~400 s to resolve and now takes under a second.
- A folder the import can't write in, a destination something else wrote meanwhile, and a failed move each
  give one line rather than a traceback; SIGTERM cleans up like Ctrl-C.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/bundle/importer.py Outdated
Comment thread src/agent_env/bundle/importer.py Outdated
Comment on lines +228 to +232
if self.dest.is_dir():
self.dest.rmdir() # empty, as _check_destination found it
emptied = True
os.replace(staging, self.dest)
except OSError as e:

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 Interrupted imports delete empty folders

The new SIGTERM handler raises KeyboardInterrupt throughout the import, but _move restores an empty destination only after OSError. If the signal arrives after self.dest.rmdir() and before os.replace, the destination stays deleted and write removes the staged bundle.

Defer interrupts across the move or restore the destination when an interrupt stops it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/bundle/importer.py
Line: 228-232

Comment:
**Interrupted imports delete empty folders**

The new `SIGTERM` handler raises `KeyboardInterrupt` throughout the import, but `_move` restores an empty destination only after `OSError`. If the signal arrives after `self.dest.rmdir()` and before `os.replace`, the destination stays deleted and `write` removes the staged bundle.

Defer interrupts across the move or restore the destination when an interrupt stops it.

---

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 5a3c2bf: _move now puts the emptied destination back on any exception, an interrupt included, and re-raises the ones that are not OSError. Covered by test_an_empty_destination_is_put_back_when_an_interrupt_stops_the_move.

…empty destination back on an interrupt

- An @Local output that takes its last segment is numbered (-2, -3, ...) when another output or a store
  reference of its kind in the task already has that name, and the steps reading it follow.
- An interrupt between emptying the destination and moving the import into place puts the empty folder back.

Co-Authored-By: Claude Opus 5.5 (1M context) <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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant