Skip to content

feat(workspace): cache devcontainer images and VS Code server payloads - #46

Merged
clintberry merged 4 commits into
mainfrom
feat/devcontainer-prebuild-cache
Jul 30, 2026
Merged

feat(workspace): cache devcontainer images and VS Code server payloads#46
clintberry merged 4 commits into
mainfrom
feat/devcontainer-prebuild-cache

Conversation

@clintberry

Copy link
Copy Markdown
Contributor

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_REPOSITORY set, 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_DIR stops 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 up argv, same provisionAgentTools call 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 stays status: active.

What each cache does

DEUCE_PREBUILD_REPOSITORY DEUCE_VSCODE_SERVER_CACHE_DIR
Removes devcontainer build + Pi install per session ~120MB VS Code server download per recreate
Keyed by repo + devcontainer-definition hash workspace
Invalidated by a devcontainer.json/Dockerfile change workspace deletion
Failure mode falls back to building from scratch costs a re-download
flowchart LR
  A["devpod build<br/>--repository R --skip-push"] -->|"R:devpod-&lt;hash&gt;"| B["docker build<br/>Deuce layer: Pi + tools"]
  B -->|"R:deuce-&lt;hash&gt;"| C["devpod up<br/>--devcontainer-image"]
  C --> D["container: no build,<br/>no over-ssh install"]
Loading

<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 fails UNAUTHORIZED against index.docker.io and 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 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.

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 HOME from the passwd database on every RUN. Docker's USER directive changes the uid but not $HOME. Without this, Pi installs into /root and the session's remoteUser cannot 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 build tag, 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 deployed deuce binary 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

EnsurePrebuild runs devpod build on 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, gofmt clean; 13 packages green.
  • New unit coverage for tag parsing against real devpod v0.6.15 output (ANSI codes included), baked-tag derivation including the registry:port case, argv assembly with and without prebuild, cache-path traversal rejection, docker-failure degradation, and HOME resolution in the Dockerfile.
  • Two opt-in end-to-end tests that shell out to real devpod and docker (DEUCE_PREBUILD_E2E=1 go test ./internal/workspace/ -run EndToEnd -timeout 20m), both run for this PR:
    • Prebuild: cache hit on the second call, devpod up logged no build step, and in-container pi 0.82.1 + the ask-user extension were present as the vscode remoteUser.
    • VS Code cache: payload saved, container deleted and recreated, confirmed absent from the fresh container, then restored owned by vscode and 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.

Signal Healthy Investigate
devcontainer prebuild image ready once per repo + definition hash fires every create → hash churn, cache never hits
using cached baked prebuild image on every session after the first absent → check the bake step's warning
toolsBaked=true on devpod workspace ready with prebuild enabled false → bake failed, see the warning below it
baking agent tools into prebuild image failed never present → sessions still work but pay the SSH install
prebuild failed; falling back to from-scratch never present → check devpod build output in the workspace log
restored vscode-server payload from cache on recreate of a workspace opened in VS Code absent → check failed to cache vscode-server payload

Rollback: unset DEUCE_PREBUILD_REPOSITORY and DEUCE_VSCODE_SERVER_CACHE_DIR and 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.


Compound Engineering
Claude Code

clintberry and others added 4 commits July 28, 2026 21:31
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
@clintberry
clintberry merged commit 04fab7a into main Jul 30, 2026
2 checks passed
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