mirror of
https://github.com/kvcache-ai/AgentENV.git
synced 2026-11-05 01:17:36 +00:00
[GH-ISSUE #141] RFC: per-step OverlayBD layer caching and build-context transport for E2B template builds #51
Labels
No labels
bug
documentation
enhancement
enhancement
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/AgentENV#51
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @JoyboyBrian on GitHub (Aug 7, 2026).
Original GitHub issue: https://github.com/kvcache-ai/AgentENV/issues/141
TL;DR: Adopt the reviewer's per-step cache, while retaining a slim shared context-staging path required by stock-SDK upload timing and cross-node routing; validate it through Phase 0/0.5 before enabling per-step reuse.
Affected component
Snapshot / template builder (cross-cutting: API server / proxy, gateway / scheduler in later phases).
RFC intent and possible closure
The #71 review proposes an execution model — cache every mutating build step as an OverlayBD layer and resume the longest completed prefix — that this RFC recommends adopting, subject to the evidence and constraints gathered here. One stock-SDK constraint remains to be placed: context uploads complete before the build-start request, and with AgentENV's current independent request routing those bytes need a staging home until the consuming step runs. The RFC therefore proposes retaining a smaller context store as transport and a
presentoracle — not as the build cache — and asks for decisions on:Useful closure outcomes: agreement on the target model with the stack revised to match; agreement on a different staging design from §Alternatives; or a recorded decision that execution caching is deferred and the stack lands as a compatibility-only milestone.
Problem statement
The stock E2B SDK's
Template.build()has one hard correctness requirement AgentENV does not meet, and one capability the protocol anticipates that AgentENV lacks:COPY/ADDthroughGET /templates/{templateID}/files/{hash}andPUTs a tar archive to the returned URL with a bare HTTP client, finishing all uploads before it sends the build-start request (Template.build). Without a place to accept those uploads and make the bytes available to the build node,COPYbuilds cannot work at all.TemplateStep.force,TemplateBuildStartV2.force,NodeDetail.cachedBuildsexist in AgentENV'ssrc/api/openapi.yml— which AgentENV parses and ignores; implementing a step cache gives those fields their intended function (the schema exposes cache control, though it does not by itself mandate any particular cache granularity).The open PR stack (#71–#74) implements transport via a repository-backed archive store, host-side COPY planning, and envd-streamed execution — but no execution caching. The #71 review asks whether the durable store is necessary and proposes per-step layer caching as the deduplication mechanism. This RFC answers with a combined design and a survey of how the same problem is solved elsewhere.
Use case
COPY/ADDagainst single-node POSIX and multi-node OSS deployments.PUT, and the build start are routed independently by the gateway today (services/gateway/internal/schedule_hint.gorecognizes only/sandboxes*routes), so any node may serve any of them.Current behavior and workarounds
src/template/runner.rs); there is no step caching, so every rebuild pays the full step cost.importis anexistscheck followed by an unconditionalPUT, not an atomic first-write publication (src/snapshot/repository/backends/oss/build_files.rs); the OSStry_start_buildtransition is an unguarded read-modify-write, and the OSS client has no conditional-write primitive at all (src/snapshot/repository/backends/oss/{repository,client}.rs— an in-repo comment notes the S3-compatible path supports neitherIf-None-Matchnorx-oss-forbid-overwrite); the gateway does not classify a bare uploadPUTas streaming, so it inherits the short request deadline (services/gateway/internal/server.go,isStreamingRequest); and gateway error logs serialize the full upstream URL including the bearer query token.force/cachedBuildsare dead schema fields.Desired behavior
forceUploadby other builds; repeated link requests never silently rebind an existing binding to a newer generation.forceon a step or build, when the build runs, then that step and everything after it re-execute, and refreshed results replace the stale cache entries without leaving mixed-generation chains observable.Ready, no later write demotes it).These are scoped guarantees, not absolutes: re-upload happens when retention expired before any binding or scope differs; re-execution happens on
force, fingerprint change, or a lost cross-node publication race.SDK constraints and current deployment assumptions
Observed behaviors of the stock SDKs (pinned: SDK @
cab27aa, infra @b8bba83):POST /v3/templates(register) → per-COPY hash computation →GET /templates/{templateID}/files/{hash}per COPY instruction →PUT <url>for each missing archive → only after all uploads complete,POST /v2/templates/{templateID}/builds/{buildID}→ status polling. The JS SDK parallelizes link requests and uploads; the Python SDK is sequential in both its sync and async clients; all finish uploads before build start (index.ts, template_sync/main.py, template_async/main.py).COPY <src> <dest>string, then each file's context-relative path, mode, size, contents, and symlink targets; uid/gid/mtime deliberately excluded; files sorted for determinism (utils.tscalculateFilesHash). It is a digest of the step's semantic inputs, not of the uploaded tar bytes — the instruction string is not present in the tar, so the server cannot recompute or verify this hash from the uploaded archive.PUTis a bare HTTP client: no auth headers; the URL is the credential; exactContent-Lengthrequired (S3 presigned PUT rejects chunked encoding; see the in-code comment in buildApi.ts referencing e2b-dev/e2b#1243). The default client upload timeout is 1 hour (configurable).present: truenormally skips the upload, but a per-instructionforceUploadboth re-uploads despitepresentand marks that COPY stepforce— and it does so using the URL from the same link response, without a secondGET(upload loop, COPY construction). Every link response must therefore carry a usable upload URL,presentor not.(templateID, hash)plus the request's auth context. No step-prefix computation is possible when answeringpresent.Deployment assumptions (properties of AgentENV today, not SDK law — changing them is part of the design space in §Alternatives considered):
round_robin/random, no template pinning), so today the shared snapshot repository is the only thing making them coherent. AgentENV registration mints a fresh snapshot ID per build request (v3_templates_post→SnapshotId::generate()), so nothing about the protocol pins consecutive requests to one node.Prior art
E2B upstream: a context store plus a per-step layer cache, joined by
filesHashE2B's build-cache bucket contains two kinds of objects (paths.go; the path helpers take a generic
cacheScope— the public API passes the team ID, with a server-side fallback to the template ID when unset):present= blob exists (template_layer_files_upload.go, upload_layer_files_template.go; 30-minute URL TTL). Self-hosted deployments can emulate the presigned URL with an HMAC-signed upload endpoint over filesystem storage; any-node coherence then requires that storage to be shared (a shared mount or object store) rather than a single node's disk.H(parentHash ‖ stepType ‖ join(args) ‖ filesHash)(phases/steps/hash.go) — the "tree of cache keys with prefix matching" from the #71 review. All phase hashes are computable statically from the recipe; the engine computes and probes them phase by phase in forward order, continuing to probe after a miss (phase.go); a hit skips that phase'sBuild().forceis sticky (builder.go); two global invalidation levers exist (a hashing version folded into the base hash; a minimum cached-format version at lookup — cache.go).BuildKit: transfer and caching are orthogonal, joined by a content digest
BuildKit (
8f96667) has no durable context-archive store; it persists the client's context as a live snapshot keyed by a heuristicsharedKey, updated by an incremental stat-based diff that only transfers changed file bodies (source/local/source.go, fsutil receive.go @6d9dc2e). The local-context vertex is deliberately uncacheable across sessions (ops/source.go); all COPY cache reuse comes from a server-side content checksum attached as an alternative cache key on the consuming step (edge.go, contenthash).Relevant lessons: the E2B protocol shape (client hash + whole-archive upload) trades away incremental transfer and gains a one-round-trip skip plus node independence; the cost — one changed byte re-uploads the whole archive — must be accepted explicitly, and in a VM builder the per-step capture cost (full-VM memory) may dominate overall build time regardless of transfer efficiency, so benchmarks decide. In BuildKit's remote-cache export,
minmode exports the layers of the resulting image whilemaxadditionally exports intermediate-stage layers (cache mode docs); on OverlayBD the writable upper is the per-step rootfs diff, so exporting intermediate rootfs layers carries little marginal data cost (sealing is O(mapping count), not free — §Proposed approach). BuildKit's GC tiers by reproducibility — cheap-to-reproduce classes get short-TTL small-budget tiers (gcpolicy.go) — and identical in-flight work is deduplicated per vertex.Industry survey (trade-off examples, not verdicts)
bef1b70, Images guide: breaking one layer "causes cascading rebuilds for all subsequent layers"). Modal distinguishesforce_build(rebuild and overwrite cache) fromignore_cache(rebuild without publishing).dockerfile + sorted(contextHashes)for the whole build; intermediate BuildKit state is runner-local, while completed snapshots propagate across runners (v0.190.0, snapshot manager). A coherent store-only + whole-build-cache design; reuse granularity is the whole build.--local-only, community reports of largeCOPY . .contexts); Fly now defaults to Depot-managed builders (announcement). These illustrate the operational coupling that builder-affinity approaches take on; they are not evidence that such approaches fail.236ba56): rolling composite key seeded by the base-image digest, extended per instruction with content hashes of referenced files; the cache index is an OCI registry, shared across machines by construction; strict stop-after-first-miss (composite_cache.go, caching docs).4f5c597): BuildKit ≥ 0.22 with the overlaybd snapshotter builds native OverlayBD images; in the native writable-layer mode the snapshotter'sCommit()seals the live writable layer into an immutable lower per committed filesystem snapshot (QUICKSTART, buildkit docs/overlaybd.md). The conversion path keeps a dedup database keyed by OCI chain_id — a cumulative parent-chain identity used for conversion dedup (overlaybd_builder.go). This validates per-step filesystem-diff sealing on this storage format; AgentENV's repeated full-VM checkpoint loop (Phase 1) goes beyond what upstream exercises and is validated by this RFC's own benchmarks and tests.Proposed approach
Execution model
Adopt the #71 review's model. A template build becomes:
final_keyfirst; on a miss, probe resumable checkpoint keys from the last mutating step toward the base until the deepest valid entry is found (validation includes lineage, below); after choosing it, execute the full suffix without further cache probes. (This is a design choice for VM builders — resuming is the expensive operation, so we pick the resume point once; E2B's engine instead probes forward per phase and can skip later phases after a miss.)CommandContextrestores the logical build state — verify it against the recipe-derived expected context, replay any metadata-only steps in the suffix, execute the mutating steps, checkpointing after each.This Phase-1 design needs no new artifact format (entries are committed snapshots; layers are ordinary managed layers), but it does need new repository metadata (context bindings, index records, attempt records, a hidden record purpose) and a metadata coordinator on multi-node OSS — it is not "zero new primitives."
Metadata coordination and fencing (required decision)
The design needs linearizable metadata transitions: context-generation publication, upload intents, build bindings and freeze, index create-if-absent and fenced replace, attempt acquisition/renewal, lease grants, GC barriers. On POSIX these map to
link/rename/O_EXCL. On the OSS backend no such primitive exists today: the client is unconditional GET/PUT/DELETE (src/snapshot/repository/backends/oss/client.rs), and an in-repo comment records that the Alibaba-OSS S3-compatible path supports neitherIf-None-Matchnorx-oss-forbid-overwrite(src/snapshot/repository/backends/oss/repository.rs). Coordinator options:If-None-Matchconditional puts in August 2024 and ETag-basedIf-Matchconditional writes in November 2024; support varies across S3-compatibles and is absent on Alibaba OSS's S3 path). Capability-detected; simplest where available.scheduler.redis_addralready backs scheduler binding HA).Recommendation: (b) for multi-node OSS, (a) opportunistically, (c) as the fallback posture. The coordinator is a Phase-0 requirement for multi-node OSS — generation publication, bindings,
Waitingexpiry, and atomic quota admission need it even with the execution cache disabled.Coordinator durability contract. The scheduler's existing Redis usage is droppable routing state; this is not. Bindings, intents, attempts, promotion intents, paused-sandbox roots, and GC leases are correctness-grade: the coordinator must run with AOF/replication and no eviction, or the deployment must be able to rebuild coordinator state from durable repository records. After a coordinator loss, GC remains closed until a recovery barrier completes (state rebuilt or verified); "coordinator reachable again" alone does not reopen GC. This contract is part of the coordinator capability definition.
Fencing across systems. A coordinator check followed by an unconditional worker write to OSS or an OCI registry is not fencing: the worker can lose its lease between check and write. The rules:
Readyvisibility — are never performed as unconditional worker writes; they are steps of the promotion outbox (§Cache publication), each gated by an intent-state CAS, executable by any actor but only in intent order. (Today's OSS publish writes artifacts, then alias, then the committed record unconditionally —src/snapshot/repository/backends/oss/{layout,repository}.rs— which is exactly the pattern the outbox replaces for promotion.)Cache identity and the trust boundary
filesHashis a client-computed semantic digest the server cannot verify (constraint 2), and current API auth accepts any non-empty credential with no identity (src/api/impls/auth.rs, in-code TODO). Cache identity therefore cannot rest on the client hash alone.Context bindings (generational, with upload intents). The store keeps:
Every
GET files/{hash}returns a usable upload URL backed by a created-or-reusedUploadIntentwith a reserved generation — including whenpresent: true, because the stock SDK'sforceUploadPUTs to the URL from that same response without a secondGET(constraint 4). Binding is create-if-absent: if the build already has a binding for this hash it is renewed, never rebound; if a published generation exists and no binding does,present: trueand the current generation is bound; if none exists,present: false. Intents that are never used simply expire.A
PUTnever writes the reserved generation's object key directly. The body streams to a unique temporary object; the server computesblob_digestand publishes the bytes as an immutable content-addressed blob. Intent completion and binding replacement are then one atomic coordinator transition, mutually exclusive with build-start freeze:Only after this CAS does the server return 2xx. Replays of the same grant token: same digest against an already-
Completedintent succeeds only if the build's current (or frozen) binding still is that generation — otherwise it returns a post-freeze conflict; a different digest conflicts outright. There is no window in which a completed intent can rebind a build that has since frozen, and concurrent replays can never overwrite each other's bytes.forceUploadtherefore works with no special casing: the re-upload completes the reserved intent, producing a new generation bound to this build (and the SDK independently marks the stepforce); other builds' bindings keep their pinned generations.Build-start is one coordinator transition:
freeze bindings + record request digest + acquire attemptatomically; the key chain is computed from the frozen(filesHash, blob_digest)pairs, and every recipe-referenced hash must have a frozen binding. After freeze, allPUTs and rebinds for the build are rejected. Registered-but-never-started builds carry aWaitingdeadline; expiry releases bindings and intents.GC never deletes a generation with live bindings. A malformed or digest-mismatched object is treated as absent/repairable, never as permanently
present. (blob_digestdetects corruption and pins identity; it cannot prove semantic correspondence between tar andfilesHash— containment for a poisoned upload is scoping plus the fact that the poisoned generation only feeds cache keys derived from it.)Scoping. Archives, bindings, index records, and cache entries are namespaced by tenant scope, not template ID — registration mints a fresh snapshot ID per build (
v3_templates_post), so per-template scoping would destroy exactly the cross-build reuse this design exists for. Until authenticated tenancy lands, deployments must explicitly setsingle_trust_domain = trueto enable the upload/cache surface — an operator acknowledgment, not a silent default; the scope key is threaded through from day one so real tenancy is a configuration change, not a redesign.Key structure. Keys are computed over a versioned, length-delimited canonical encoding (no ambiguous string concatenation). Step classification:
RUN,COPY/ADD, andWORKDIR(it runsmkdir -pin the guest) are mutating;ENV,USER,EXPOSED_PORT,VOLUME,LABELare context-only (USERsubject to the execution-identity fix below). Two key kinds exist:fingerprintis derived from the canonical effective launch configuration (not a hand-maintained field list); it must cover at least: resolved base image manifest/layer identity, architecture, disk size, vCPU/memory geometry, virtualization mode, CPU config/template, Firecracker/kernel/envd/tools-image versions, snapshot & cache format versions, and a builder-semantics version.canonical(finalization inputs)covers the normalized/defaulted ready command, the startup command and whether it was inherited or explicit, the effective finalCommandContext,ensure_default_userbehavior, and a finalization-semantics version.vis the global hashing-version lever; a minimum cached-format version is enforced at lookup (both per E2B).Execution-identity exclusion (
USER). TodayUSERonly updates the host-sideCommandContext(src/template/step_executor.rs), andRUNpasses envd only env vars and cwd — not the user — so a cold build executes everyRUNas envd's boot-time default user. But resuming from a checkpoint re-initializes envd with the snapshot's storeddefault_user(src/sandbox/firecracker/sandbox.rs, envdinit), so the same suffix would execute under a different user than a cold build — a cold-miss/resume divergence. Phase 1 therefore excludes recipes containingUSERfrom the execution cache entirely — they neither read nor write cache entries (they build exactly as today). The proper fix — exec/process requests carrying the effective user explicitly, which also makes cold builds honorUSERper Docker semantics — is follow-up work; when it lands, the envd/runtime version enters the fingerprint so old checkpoints with the divergent semantics can never be resumed under the new ones. A cold-miss vs resume equivalence test for the cached step vocabulary is mandatory regardless.Custom extension. The
start-freshhook may inject instance-specific boot args while cache hits resume viastart-resume— two semantically different paths, and today's hook API has no way to declare cache equivalence. When[custom_extension].urlis configured, the cache is disabled. A real opt-in would need a defined contract (contract version, cache salt, canonical effective params, and explicit authorization of skipped hooks) — future work, out of scope here.forcesemantics.force(build-level, per-step, or induced byforceUpload) bypasses cache reads for the forced step and, stickily, everything downstream. Refreshing shared entries is done safely via lineage (below): forced results replace pointers with a generation-CAS fenced through the attempt state machine; displaced targets become delayed-GC orphans.Index records, lineage, and validation
Two index record kinds exist:
CheckpointEntry.parentnames the nearest resumable ancestor (skipping context-only steps), or a typed root edge{base_key, base_identity}for the first mutating step (a fresh image base has no parent snapshot). Metadata-only steps contribute to the key chain but never resolve to snapshots — their effects live in the next checkpoint's stored post-stepCommandContext, and on resume the builder replays any metadata-only suffix steps deterministically.snapshot_idmatches the parent key's current entry, or the root edge matches the build's base identity). This applies toFinalResultEntrytoo — a final hit validates the full lineage back to the root, so a forced replace of any ancestor also invalidates stale final pointers. Descendants of a replaced ancestor fail lineage and are treated as misses (and queued for repair) instead of silently gluing mixed-generation chains together.build_base_checkpoint_idis optional on final entries: Phase 0.5 takes no pre-finalization capture, so its promoted templates lack the field and derived builds from them fall back to cache-disabled semantics.Cache entry publication and promotion
Cache checkpoints are committed snapshots with a
BuildCacherecord purpose and cache-schema version. Hidden means enforced, not cosmetic: every public resolve/get/status/delete/alias/launch path rejectspurpose = BuildCacherecords (todayGET/status/delete and sandbox launch accept any snapshot UUID —src/api/impls/template.rs,src/api/impls/sandbox.rs); only internal cache APIs may read them. To keep rollback safe, the cache catalog lives in a separate repository namespace that older binaries do not scan; alternatively a downgrade requires disabling admission, draining leases, purging cache/P2P state, and completing GC first.Two publication paths, both required:
publish_cache_candidate: always stores layers as managed/object-storage layers and never publishes source-registry OCI tags or takes publication ownership — regardless ofsnapshot_image_storage = source_registry. Candidate metadata retains per-disk origin repositories from the original image resolution: managed materialization rewrites layer URLs to AgentENV storage, and the current exporter derives the target repository fromimage.json'srepoBlobUrl(src/snapshot/repository/backends/common/acr/source_image.rs) — without recorded provenance, promotion could no longer publish to the source registry.promote_committed_snapshot: produces the final user snapshot under the target build's snapshot ID, driven by aPromotionIntentthat works as a transactional outbox, with per-disk plans (rootfs and each attached drive may publish to different repositories/tags):Entering
Publishing(writing the intent) is the irreversible commit decision: from that point the intent converges forward toReady— lease expiry hands it to the promotion reconciler (the named recovery actor for node death mid-promotion), it never triggers aFailed-plus-cleanup path, so no cleanup can race a late writer.Failedis declared only for authoritative, permanent errors, and its cleanup follows the ownership rules below. Two write classes are distinguished:Ready) are never unconditional external writes: the record and alias content are staged at intent-qualified immutable paths, and canonical visibility is a coordinator pointer flip — public reads resolve the target/alias through the coordinator-committed pointer, so a fenced-out worker's stale staging is unreachable by construction.registry_committedis reached only when every disk plan's tag push has completed, each push is idempotent-by-content (the plan carries exact config/manifest bytes with the per-diskownership_nonceembedded in the OCI config), and duplicate executors of the same intent write identical bytes.Registry adoption and ownership. The current publisher requires the tag to be absent before pushing (
ensure_manifest_absent,src/snapshot/repository/backends/common/acr/publisher.rs), so a crash between registry PUT and record write would make a naive retry fail forever. With the per-disk plans, a reconciler retry adopts an existing manifest only when its digest matches that disk plan — proving it is this promotion's own remnant, not another writer's tag — and a crash after publishing only some drives' tags resumes with the remaining plans. Any cleanup deletes a tag only after re-reading that it still points at the intent's digest; when ownership cannot be proven, the artifact is left as an orphan rather than deleted.Each mutating checkpoint stores its exact post-step
CommandContext—CommittedSnapshotalready carriescontext, and the resume path already initializes envd from it (src/sandbox/firecracker/config.rs); the builder replays the recipe's metadata effects only to verify the stored context, never as the source of truth.fromTemplatebuilds (Phase-1 requirement, not an option). The current runner captures the user snapshot after startup/finalization (src/template/runner.rs), so buildingfromTemplateon the final snapshot would resume a finalized VM and re-run startup on top of it. User templates therefore recordbuild_base_checkpoint_id— the checkpoint taken afterensure_default_userand before startup/finalization — in snapshot metadata (a GC root, preserved across rollback). The final candidate, the singleflight shared result, and thePromotionIntentall carry and pin this ID, so every waiter's promotion writes it into its own user snapshot. Derived builds extend the build-base checkpoint (startup runs exactly once, in the finalization phase of whichever build publishes); sandboxes keep launching from the final snapshot. Templates predating the field fall back to today's semantics with the cache disabled for that build.Build identity (MMDS). Every build VM currently receives its sandbox/snapshot ID via MMDS (
src/sandbox/firecracker/mmds.rs). The contract:SandboxIdremains unique per VM; only the MMDS payload presented to cache-eligible build VMs uses a stable, cache-neutral identity — from Phase 0.5 onward (any build whose result may be promoted under a different identity).forcedoes not help here: forced builds still run in neutral-MMDS mode and refresh the shared cache with neutral values.Publication, concurrency, and races
Concurrent identical builds are not assumed to produce identical bytes:
RUNmay depend on time/network/randomness, and independently sealed OverlayBD uppers embed distinct UUIDs, so layer digests normally differ even for equivalent work. Therefore:force). Exactly one candidate wins per key; losers are complete-but-unreferenced snapshots handled as delayed-GC orphans. (Managed-layer digest dedup still collapses genuinely identical layers to zero bytes.)(scope, final_key)— scoped so distinct tenants never merge flights. The leader executes the remaining suffix and publishes the neutral cache candidate (with itsbuild_base_checkpoint_id); each waiter then performs its own promotion under its own build ID and attempt epoch from that shared candidate. A leader's promotion failure (alias, registry, target publication) affects only the leader. Two builds never share a live mutable VM — only completed immutable checkpoints. Per-step rendezvous is deferred. Forced builds bypass flights entirely.Failure contract
Build execution today is a detached in-process task holding all of its state in memory (
src/api/impls/template.rs), and the OSSWaiting → Buildingtransition is an unguarded read-modify-write — node death leavesBuildingforever. This RFC requires:A durable attempt record per build with an explicit state machine and transition-specific fences:
The owner renews the lease while
Active. The attempt reconciler transitions expired-leaseActiveattempts to the existingErrorstatus with reasonNodeLost(no new schema status) and releases bindings/quota; expired-leasePublishingattempts are instead handed to the promotion reconciler, which drives the intent forward toReady(§Cache publication) —Failedthere is reserved for authoritative permanent errors, never for lease expiry. Terminal states are monotonic —Readycan never be overwritten by a late error; a closed attempt rejects all subsequent same-epoch CAS attempts by construction.The minimal attempt lifecycle ships in Phase 0:
Waiting → Active(lease) → Ready | Error, with renewal, the expiry reconciler, and terminal release of bindings, intents, and quota — because Phase 0 already introduces freeze+acquire and durable bindings that would otherwise pin forever when a node dies. Phase 0.5 addsPublishingand the promotion outbox; Phase 1 adds checkpoint progress tracking.Phase-1 failure policy: fail visibly, do not requeue. A user retry is a new build that reuses all committed checkpoints, which is where the cache already does the heavy lifting. Durable automatic requeue would additionally require persisting the full canonical
BuildJobSpecand is deferred — listed as an open question.Final promotion is idempotent by
(build_id, attempt_epoch)throughPromotionIntent; the promotion crash windows (registry tag written but record missing; candidate promoted but status not yetReady) converge via the reconciler's adopt-by-digest-and-nonce retry, and tests must cover both.Lifecycle and bounded storage
Per-step caching multiplies snapshot records and managed layers by roughly the step count, and
managed-layers/is today an append-only CAS that record deletion never touches. Bounded storage is therefore a requirement of every phase in which the cache can be enabled: the cache ships default-off, and enabling it requires retention to be implemented.Roots and leases must be cluster-visible and object-exact:
src/orchestrator/persistence/file_backed.rs), invisible to another node's GC. A coordinator-backed paused-root record is written before a pause is visible as successful.Live → Deleting → external DELETE → Deleted, held in the coordinator.Deletingblocks new leases and new references; a publisher that finds an object inDeletingwaits forDeletedand re-uploads rather than adopting the doomed object.P2pTransport::unpublishon the owning node) —ForgetP2pArtifactalone only drops the scheduler hint.Sweep order:
snapshot_id/blob_digest, never mutable keys).build_base_checkpoint_idreferences), coordinator-registered paused/running sandbox roots (including attached-drive and memory layers), and fixed artifacts.Deletingbarrier; delete; enqueue durable P2P unpublish.Source-registry publications never enter cache lifecycle by construction (
publish_cache_candidatecannot create them; promotion creates them under the user snapshot's identity and ownership).Depth budget and preflight. OverlayBD stacks have a hard 255-layer ceiling that binds each stack independently — rootfs, memory, and every writable attached drive (each Phase-1 checkpoint appends a memory layer too); captures whose staged runtime suffix exceeds 32 layers additionally trigger a merge-rewrite of that staged suffix (
DEFAULT_MAX_OVERLAYBD_SNAPSHOT_LAYERS— already-published checkpoint layers are not rewritten). The per-build checkpoint budget is computed dynamically:Preflight: before build start, the builder verifies the base stack can accommodate at least the reserved captures; if not, it compacts/rebases first or rejects with an actionable error — "degrade to non-checkpointing" only works when the mandatory captures still fit. Past the budget, later steps execute without checkpointing and the build still completes. (The memory term is deliberately conservative: successive captures of one live execution are typically siblings against the inherited memory base rather than an ever-growing live stack; the budget can be relaxed with benchmark evidence without affecting correctness.)
Alternatives considered
The design space decomposes into independent dimensions — where uploaded bytes land (sandbox / node disk / gateway / shared store), how long they must survive (until consumption vs durable), placement coupling (pinned build node vs any node), and failure policy (retry, re-upload, fail). Points in that space we evaluated:
POST /v3/templates, route upload-links/PUTs to that node (new gateway capability), stage in a node-local content-addressed spool. Trade-offs vs a shared store: adds scheduler/gateway coupling and exposes staged bytes to single-node loss before consumption;present: trueacross builds only holds for builds bound to the same node, unless the spool replicates — a node-local lossy CAS answers strictly fewerpresentqueries than a shared durable CAS. On single-node POSIX deployments this is effectively the proposed design (the repository is node-local there). A reasonable future direction once placement pinning exists for other reasons.present: trueit needs synchronous replication or a durable fallback — the current P2P index is best-effort and can only serve as a hint. Compatible with adopting later behind the store's staging role.present: false+ execution caching only. Protocol-compliant and simplest; every build re-uploads all context (the SDK tolerates it;forceUploadsemantics unaffected). Loses upload dedup entirely — expected to be costly for large contexts; benchmark required. Kept as a fallback posture if the store is descoped.forceis trivially honored,cachedBuildsmay legitimately be empty. It fails this RFC's execution-reuse goal, not E2B compatibility; it is the natural first milestone on the way to the full design.(recipe, frozen bindings, fingerprint)short-circuiting identical rebuilds. Not subsumed by per-step caching in operational terms: it avoids per-step O(RAM) captures, per-step hidden-record proliferation (one hidden final candidate per build remains), and depth pressure entirely, and may remain the better mode for repetitive CI rebuilds, high-memory builds, or depth-capped builds. It also exercises the same key/index/promotion/GC machinery, which is why it is proposed as its own phase rather than an alternative.Proposed composition: per-step layer caching (execution reuse) + the slimmed shared store (transport,
presentoracle, miss-time materialization). The narrow claim: under the current arbitrary request routing (D1), with cross-build/cross-nodepresent: truesemantics, and without SDK modifications, shared staging is the minimal increment. If placement pinning lands later, alternatives 2–4 can take over the staging role; placement, lease, and upload-routing interfaces would change, while the execution-cache key/checkpoint model carries over unchanged.Compatibility and operational impact
API/config changes:
[template_build]gains: cache toggles (default-off),single_trust_domainacknowledgment gate, retention budgets/TTLs, depth margins, coordinator configuration; grant TTL/limits carry over from #72 with single-use claims removed.forceandcachedBuildsbecome functional rather than ignored (later phases for the latter).Errorgains aNodeLostreason (no new status value).purpose = BuildCachesnapshots on every direct-ID path.Snapshot or storage format changes:
BuildCacherecords in a separate cache-catalog namespace (invisible to older binaries); cache-schema version;build_base_checkpoint_idon user snapshots; context generation/binding/intent records; index/attempt/lease/promotion-intent records; server-computedblob_digest. Committed snapshot manifests and layer formats are unchanged in Phases 0/0.5/1; Phase 2'sColdBootRootfsentries would introduce a new tagged artifact kind (see rollout — not resolved by this RFC).New host/runtime requirements:
public_base_urlis required for the upload surface.Upgrade and rollback considerations:
Waitingdeadlines, quotas, TLS requirement, coordinator on multi-node OSS); it does not change build results. Hashing-version and minimum-format-version levers allow global or format-scoped invalidation without migration. A full cache purge is safe only after: draining exact-object reader leases, draining or failing active attempts, promotion intents, and publication writers, completing queued P2P unpublish work, and resolvingbuild_base_checkpoint_idroots — checkpoints referenced by user snapshots are user-snapshot roots, not cache-owned, so the purge either preserves them or clears the field (dropping those templates to legacyfromTemplatesemantics). The separate cache namespace keeps downgraded binaries from ever observing cache records as templates.Security posture:
single_trust_domain = trueuntil authenticated tenant identity exists; scope key threaded now. Grants bind(scope, buildID, hash, reserved generation, method, exact size ceiling, expiry); quota admission is atomic and happens before the body is read; grant replay is idempotent by digest and cannot mint generations. Bearer query tokens are redacted from error logs, access logs, traces, metrics, and forwarded-URI headers.Rollout plan
present:trueand on upload 2xx, TTL grants (single-use claims dropped), tenant-scope threading +single_trust_domaingate, archive retention subordinate to bindings,Waitingdeadlines, atomic freeze+acquire build-start, minimal attempt lifecycle (Waiting → Active(lease) → Ready | Error) with renewal, theError(NodeLost)expiry reconciler, and terminal release of bindings/intents/quota, coordinator for multi-node OSS, gateway timeout/redaction fixes. #73 (copy_plan.rs, zero store deps) and #74 (envd streaming + in-guest extraction) are unchanged — they are required under every alternative above.(recipe, frozen bindings, fingerprint)with aFinalResultEntry; hit ⇒ promotion from the cached final candidate via thePromotionIntentoutbox and promotion reconciler; cache-neutral MMDS from this phase on. Exercises keys, index records, validation, promotion, retention, and the coordinator end-to-end with no per-step machinery and minimal storage growth.USERare excluded from the cache (§Cache identity; the explicit effective-user exec API is follow-up work). Then: key chain + fingerprint; logical vs resumable keys with typed root edges; runner refactored from run-all-steps-then-capture-once (src/template/runner.rs) into a per-step checkpoint loop over the existing live pause→capture→in-place-resume primitive (SandboxBackend::snapshot()); post-stepCommandContextstored per checkpoint (replay for verification and metadata-only suffixes);build_base_checkpoint_id(post-ensure_default_user, pre-startup) threaded through candidate, singleflight result, and promotion; candidate publication viapublish_cache_candidate+ lineage-validated index;(scope, final_key)singleflight with independent waiter promotion; checkpoint progress on the attempt record; mark-and-sweep retention with exact-object leases,Deletingbarriers, coordinator-backed sandbox roots, and fail-closed behavior; dynamic depth budget with preflight. Memory cost is accepted and measured: with default mincore selection each checkpoint copies O(resident RAM); with opt-in dirty-page tracking (00ba6cb) the Firecracker query is logically non-clearing (/vm/dirty-memory-ranges), so successive checkpoints re-copy pages dirtied since VM start/resume — complete images, cumulative cost. Benchmarks on both paths gate the default.ColdBootRootfscache-entry kind (new tagged artifact kind; publisher/resolver branches). Known preconditions already identified: in-guestsyncfs/fsfreezebefore sealing is a prerequisite even for the pause-based variant — a paused VM's page cache is not flushed into the OverlayBD upper, so a completedRUN's file contents can exist only in memory, and background processes/tmpfs/mounts/IPC state vanish on cold boot. Cold-boot entries would be restricted to steps declared filesystem-only/hermetic; arbitraryRUNsteps stay on Phase-1 entries; writable attached drives unsupported initially. This phase proceeds only with its own design note and safety evidence.cachedBuildsmust reflect node-local materialization/page-cache warmth to be useful), heartbeat population ofNodeDetail.cachedBuilds, and a template-build schedule hint.Acceptance criteria
Moved to the first comment below (GitHub issue size limit), together with the full commit-pinned reference list.
Non-goals
Open questions
single_trust_domain = truegate acceptable until authenticated identity lands, or should authenticated tenant ownership be a Phase-0 prerequisite?Error(NodeLost)+ user retry acceptable long-term, or is durable requeue (with a persistedBuildJobSpec) wanted — and in which phase?RUN. Rely purely onforce(proposed, Docker/E2B semantics) or add opt-in per-template cache TTL?Contribution
I can implement it.
Pre-submission checklist
Full commit-pinned reference list is in the first comment below.
@JoyboyBrian commented on GitHub (Aug 7, 2026):
Acceptance criteria
Functional:
present:false → PUT → GC pressure → build-startandpresent:true → GC pressure → build-startboth materialize the bound generation; repeatedGETs renew without rebinding;present:true + forceUploadcompletes the reserved intent and rebinds only the uploading build; grant replay with identical digest is idempotent, with a different digest conflicts (and never overwrites published bytes); post-freezePUTs are rejected.ENVandWORKDIRbetween twoRUNsteps produces identical guest state whether built cold or resumed from each checkpoint; a recipe containingUSERneither reads nor writes the execution cache and builds with today's semantics.Concurrency and failure:
Readyis never demoted by a late error.Publishingleads to the promotion reconciler driving the intent forward toReady— including the registry-tag-written-record-missing window (adopt by digest + ownership nonce), a crash after publishing only some disk plans' tags (remaining plans resume;registry_committedrequires all), and the candidate-promoted-not-yet-Readywindow; adoption never claims a tag whose digest does not match the disk plan.build_base_checkpoint_id); identical builds in different scopes never share a flight; a leader's promotion failure does not fail waiters.Error(NodeLost)via lease expiry in Phase 0 already, releasing bindings, intents, and quota; a retry build reuses all committed checkpoints.Lifecycle:
Deletingadmits no new lease and forces publishers to wait-and-reupload; GC concurrent with starting/running/paused sandboxes never removes their layers; Running↔Paused transitions acquire new roots before releasing old ones; GC fails closed during coordinator loss and stays closed until the recovery barrier completes.Benchmarks (gate any cache default):
References
E2B (pinned: infra @
b8bba834, SDK @cab27aa6)index.ts·buildApi.ts·utils.ts· Python syncmain.py· Python asyncmain.pypaths.go·template_layer_files_upload.go·upload_layer_files_template.gophases/phase.go·phases/steps/hash.go·storage/cache/cache.go·layer/layer_executor.go·commands/copy.go·build/builder.goBuildKit (pinned @
8f966671; fsutil @6d9dc2eb)source/local/source.go·solver/llbsolver/ops/source.go·solver/edge.go·cache/contenthash/checksum.go·cmd/buildkitd/config/gcpolicy.go·docs/dev/solver.md·docs/overlaybd.md· cache mode docs · fsutilreceive.goOverlayBD upstream (pinned @
4f5c597f)overlaybd_builder.go· WRITABLE.mdIndustry
bef1b706): modal-client · Images guide · container loading internals · sandbox snapshots236ba569): composite_cache.go · caching docsAgentENV internals referenced (branch
e2b-build/03-build-file-storeunless noted)src/snapshot/repository/build_files.rs,src/snapshot/repository/backends/{posixfs,oss}/build_files.rs(#71); upload APIsrc/api/build_files.rs,src/api/impls/template.rs(#72 branch); COPY planning/executionsrc/template/copy_plan.rs(#73),src/template/step_executor.rs(#74)src/sandbox/firecracker/sandbox.rs(snapshot(),pause_to_dir, envdiniton resume with stored user/workdir/env),src/sandbox/firecracker/instance.rs(in-place resume),src/orchestrator/service.rs(capture_snapshot),src/template/runner.rs(current single-capture flow; per-build MMDS identity; startup before capture)src/template/step_executor.rs(Userupdates host-side context only;Runpasses env/cwd without user)src/snapshot/types/snapshot.rs(CommittedSnapshot.context),src/sandbox/firecracker/config.rs(resume initializes envd from stored context),src/sandbox/firecracker/mmds.rs(instanceID/envID)storage/overlaybd/src/lsmt/file/readwrite.rs(close_seal),storage/overlaybd/src/image/image_file.rs,storage/overlaybd/src/image/snapshot.rs(export_upper_as_snapshot_layer, cumulative copy),src/sandbox/firecracker/overlaybd_snapshot.rs(memory conversion; staged-suffix compaction at 32 layers),storage/overlaybd/src/lsmt/file/types.rs(255-layer ceiling, applies per stack incl. memory)src/snapshot/repository/backends/posixfs/artifacts.rs; chained conversion cachesrc/image/oci_image.rs(LayerConversionKey)src/snapshot/repository/backends/oss/client.rs(unconditional GET/PUT/DELETE),src/snapshot/repository/backends/oss/repository.rs(no conditional writes on the S3-compatible path;try_start_buildRMW; artifacts → alias → record publish order;disk_publications),src/snapshot/repository/backends/oss/layout.rs(catalog/alias/record keys)src/snapshot/repository/backends/common/acr/source_image.rs(repository derived fromrepoBlobUrl),src/snapshot/repository/backends/common/acr/publisher.rs(ensure_manifest_absentpreflight)src/api/impls/template.rs,src/api/impls/sandbox.rssrc/orchestrator/persistence/file_backed.rssrc/snapshot/repository/backends/oss/build_files.rs(importexists-then-PUT),services/gateway/internal/server.go(isStreamingRequestcoverage; upstream-URL token logging),src/api/impls/auth.rs(presence-only auth TODO)src/api/openapi.yml(TemplateStep.force,TemplateBuildStartV2.force,NodeDetail.cachedBuilds)00ba6cb,thirdparty/firecracker-client/firecracker.yaml(/vm/dirty-memory-ranges)src/sandbox/custom_extension/client.rs,CLAUDE.md§Custom extension@yingdi-shan commented on GitHub (Aug 9, 2026):
Thanks for the detailed RFC. The proposal combines several distinct concerns: context transport, execution caching, distributed coordination, snapshot promotion, and garbage collection. I would prefer to avoid introducing Redis or treating build context as part of the durable snapshot repository.
My preference is to stream build context through envd into a node-local build VM. This fits the OverlayBD architecture better: storage can be bounded and reclaimed with the build, context files can be transferred on demand, and P2P can accelerate reusable artifacts without making OSS or POSIX storage the upload bottleneck.
The main difficulty is the stock E2B SDK request ordering: it uploads all files before the build-start request provides the base image and build recipe.
I think AgentENV should also consider a native Dockerfile build workflow supporting COPY/ADD and multi-stage builds. Instead of modeling a build as one server-side API call,
aenvcould orchestrate it as a sequence of API operations: create the build VM, upload context directly, execute each step, and capture the resulting snapshot. This would avoid the E2B ordering constraint while keeping the initial server-side design relatively small.@JoyboyBrian commented on GitHub (Aug 9, 2026):
Thanks @yingdi-shan this makes the intended direction clear. I'll drop the shared durable context store and the new correctness-grade external coordinator from the design; before revising #141 or starting implementation, I'd like to confirm the resulting scope.
My reading of your proposal:
aenvcreates an ordinary sandbox as the build VM, streams the required context through envd, executes the steps, and captures the resulting snapshot. Existing sandbox routing keeps these operations on the sandbox's node, so this path requires no new cross-node coordination.I propose the following initial scope:
Native workflow. Start with single-stage Dockerfiles, including
COPY/ADDandRUN. Multi-stage support would follow after explicitly defining stage filesystem access andCOPY --fromsemantics. Is that sequencing acceptable?Stock E2B SDK compatibility. The stock SDK completes its uploads before build-start provides the base image and recipe, so those bytes cannot go directly into the final build VM. If stock-SDK
COPYsupport remains in the initial scope, I propose pinning the registered build session to a node and staging uploads in a bounded, build-scoped local spool on that node. At build-start, the same node creates the VM and streams the staged files through envd.The spool would be removed when the build terminates or expires. Initially, no cross-build context deduplication would be promised (
presentmay befalsefor each new build). If the pinned node is lost before consumption, the build fails and a retry re-uploads rather than transparently resuming elsewhere. This requires a small gateway routing extension, but no durable context store or new correctness-grade external coordinator.Alternatively, stock-SDK
COPYcompatibility can follow the native workflow as a separate milestone. Do you prefer it in the initial milestone or as a follow-up?If this matches your intent, I'll update and split #141 accordingly, rework or close #71/#72 depending on (2), reuse the applicable host-side COPY planning and envd streaming pieces from #73/#74, and only then begin implementation.
@yingdi-shan commented on GitHub (Aug 9, 2026):
@JoyboyBrian commented on GitHub (Aug 9, 2026):
Thanks, understood. Recording the decision:
aenv.COPY/ADDinputs will be streamed directly into the build VM through envd; the design will not persist build-context bytes on the host filesystem.COPYcompatibility and the host-side spool proposal are out of scope for this milestone. Any future compatibility design will preserve the VM isolation boundary for untrusted context handling.I'll now create a smaller focused design issue for the initial milestone and mark #141 as superseded. #71/#72 will be closed as superseded; I'll audit #73/#74 and carry the reusable COPY planning, envd streaming, and in-guest extraction pieces into the native path. Implementation will start from
mainafter the focused design is posted.