feat(workspace): cache devcontainer images and VS Code server payloads - #46
Merged
Conversation
Sessions rebuilt each repo's devcontainer from scratch and then installed Pi, pi-subagents and the ask-user extension over `devpod ssh`, so every cold start paid a full image build plus several install round-trips. DEUCE_PREBUILD_REPOSITORY (empty by default, which keeps the existing behaviour byte-for-byte) turns on a two-stage cache: devpod build --repository R --skip-push -> R:devpod-<hash> docker build (Deuce layer: Pi + tools) -> R:deuce-<hash> devpod up --devcontainer-image R:deuce-<hash> <hash> is devpod's hash of the devcontainer definition, so the cache invalidates on a devcontainer change and survives ordinary code pushes. Later sessions skip the build and the over-ssh provisioning entirely. --devcontainer-image rather than --prebuild-repository: the latter only ever does a registry lookup, so it cannot consume a local tag. Going through the image override keeps the whole cache local to the Docker daemon, no registry required. Every failure degrades instead of breaking a session - an unparseable build tag or a failed bake falls back to the from-scratch path and the original over-ssh install. The baked layer resolves HOME from the passwd database per RUN, because Docker's USER directive changes the uid but not $HOME; without that Pi installs into /root where the session's remoteUser cannot reach it. Verified end-to-end (opt-in DEUCE_PREBUILD_E2E test): cache hit on the second call, devpod up starts with no build step, and pi 0.82.1 plus the ask-user extension are present in-container as the vscode remoteUser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014orMLVuaREojZaJ6mzXvtv
VS Code Remote-SSH installs a ~120MB server payload into the container's own filesystem, so every container recreate re-downloaded it. DEUCE_VSCODE_SERVER_CACHE_DIR (empty by default = off) copies the tree out to a host cache before an action that destroys the container, and copies it back in after the container is recreated. The plan called for a per-user named volume; DevPod exposes no way to add one. `devpod up` has no mount flag and the docker provider's options are only DOCKER_BUILDER/DOCKER_HOST/DOCKER_PATH/INACTIVITY_TIMEOUT, so mounts can only come from the repo's own devcontainer.json, which Deuce does not control. `docker cp` is the mechanism actually available, and it turns a network download into a local disk copy. The cache is keyed by workspace rather than by user. A workspace's container is already shared by every member of its session, so a per-workspace cache adds no new reach; keying it per user would copy one user's extension state, including whatever credentials their extensions have stored, into a container other session members hold a shell on. Saves stage into a sibling .partial directory and are promoted by rename, so an interrupted copy cannot leave a half-written tree that a later restore would push into a container as if it were complete. Caches are purged when their workspace is deleted. Home is resolved from the passwd database rather than $HOME, which `docker exec` does not set. Every operation is best-effort: a failure costs a re-download, not a session. Verified end-to-end (opt-in DEUCE_PREBUILD_E2E test): payload saved, the container deleted and recreated, confirmed absent from the fresh container, then restored owned by the vscode remoteUser and writable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014orMLVuaREojZaJ6mzXvtv
docker reports an absent source path as "Could not find the file <path> in container <id>". The check matched "not found", which that string does not contain, so a workspace that had never been opened in VS Code logged a save failure on every stop and rebuild. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014orMLVuaREojZaJ6mzXvtv
Both were documented in CLAUDE.md but missing from the example env file, which is where an operator configuring a deployment actually looks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014orMLVuaREojZaJ6mzXvtv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Opening a session no longer has to rebuild a repo's devcontainer from scratch and then install the agent harness over SSH. With
DEUCE_PREBUILD_REPOSITORYset, a repo's devcontainer image is built once with Pi, pi-subagents and the ask-user extension already baked in; every later session starts straight from that image with no build and no install round-trips. Separately,DEUCE_VSCODE_SERVER_CACHE_DIRstops VS Code Remote-SSH re-downloading its ~120MB server payload every time a container is recreated.Both are opt-in and off by default. With neither variable set, workspace creation is byte-identical to today — same
devpod upargv, sameprovisionAgentToolscall sites. There is a test pinning that.This is Phase 1 of
docs/plans/2026-06-16-003-feat-devcontainer-fast-init-and-gui-plan.md(U1 + U3). Phase 2 (U4, the in-container desktop) is deliberately not here, so the plan staysstatus: active.What each cache does
DEUCE_PREBUILD_REPOSITORYDEUCE_VSCODE_SERVER_CACHE_DIR<hash>is devpod's own hash of the devcontainer definition, so cache invalidation comes for free: changing the devcontainer yields a new hash and a new pair of tags, while ordinary code pushes reuse both.Design decisions worth reviewing
--devcontainer-image, not--prebuild-repository. The plan assumed the latter. It only ever does a registry lookup — pointed at a local tag it failsUNAUTHORIZEDagainstindex.docker.ioand silently falls back to devpod's per-workspace cache name, which is not shared across sessions. The image override is what actually consumes a local tag, so the whole cache stays on the Docker daemon and no registry is required.docker cp, not a named volume. Also a plan deviation, and not a preference — DevPod exposes no mount lever at all.devpod uphas no mount flag and the docker provider's options are onlyDOCKER_BUILDER/DOCKER_HOST/DOCKER_PATH/INACTIVITY_TIMEOUT, so mounts can only come from the repo's owndevcontainer.json, which Deuce does not control.The VS Code cache is keyed by workspace, not by user. A workspace's container is already shared by every member of its session, so a per-workspace cache adds no new reach. Keying it per user would be a bigger win but would copy one user's extension state — including whatever credentials their extensions have stored — into a container other session members hold a shell on.
The baked layer resolves
HOMEfrom the passwd database on everyRUN. Docker'sUSERdirective changes the uid but not$HOME. Without this, Pi installs into/rootand the session'sremoteUsercannot reach it — the agent then fails to launch for reasons that look nothing like the cause. Pinned by a test.Everything degrades instead of breaking a session. An unparseable
devpod buildtag, a failed bake, or a failed cache op falls back to the original path. Tag parsing reads a log line, which is inherently fragile; a devpod upgrade that rewords it costs the cache, not the session.The Dockerfile is a Go constant, not
deploy/workspace-image/as the plan specified. A deployeddeucebinary would otherwise depend on the repo layout sitting next to it. It is generated into a temp build context from the same constants the over-SSH installers use, so the two paths cannot drift.Trade-off accepted
EnsurePrebuildrunsdevpod buildon every create, which clones the repo. The win is skipping the image build and the Pi install, not the clone — devpod needs the clone to compute the hash that makes staleness detection work, so making it conditional would mean caching a hash we cannot compute without it. A background-refresh variant would remove the clone if it proves to matter in practice.Test plan
go build,go vet,gofmtclean; 13 packages green.registry:portcase, argv assembly with and without prebuild, cache-path traversal rejection, docker-failure degradation, andHOMEresolution in the Dockerfile.DEUCE_PREBUILD_E2E=1 go test ./internal/workspace/ -run EndToEnd -timeout 20m), both run for this PR:devpod uplogged no build step, and in-containerpi 0.82.1+ the ask-user extension were present as thevscoderemoteUser.vscodeand writable.Review caveat: this diff's size and surface would normally warrant a multi-persona review pass, which the authoring session was not permitted to spawn — it got a single careful read instead. That read did find one real bug, fixed in
0cf6400: docker phrases an absent path as "Could not find the file", which does not contain "not found", so a workspace never opened in VS Code would have logged a spurious save failure on every stop and rebuild. A second pass on the docker-argv construction and the fallback paths would be worthwhile.Post-Deploy Monitoring & Validation
Both features are off unless their env var is set, so the no-op deployment risk is a config typo — which is caught at startup by
Config.Validate()rather than at first session create.Enable on one host first, watch a cold and a warm session start.
devcontainer prebuild image readyusing cached baked prebuild imagetoolsBaked=trueondevpod workspace readyfalse→ bake failed, see the warning below itbaking agent tools into prebuild image failedprebuild failed; falling back to from-scratchdevpod buildoutput in the workspace logrestored vscode-server payload from cachefailed to cache vscode-server payloadRollback: unset
DEUCE_PREBUILD_REPOSITORYandDEUCE_VSCODE_SERVER_CACHE_DIRand restart. No migration, no persisted state to unwind; stale images and cache directories can be removed at leisure.Disk: prebuilt images accumulate per repo + definition hash, and VS Code caches run ~120MB per workspace opened in VS Code. Cache entries are purged on workspace delete; stale prebuilt images have no GC yet — watch disk on the first host and prune by tag if it grows.
Window/owner: first day of real session traffic, whoever enables the flags.