diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7987a42..8c754cc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,13 +25,26 @@ jobs:
go-version-file: go.mod
- name: gofmt
run: make fmt-check
- - name: vet
- run: go vet ./...
+ - name: vet (including the e2e and live test files, which are not run here)
+ run: make vet
- name: test
run: go test -race -count=1 -coverprofile=coverage.out ./...
- name: cross-build linux amd64/arm64
run: make build-linux
+ # The adapter kinds left out of the default build (experimental_adapters
+ # tag) must keep compiling and passing their fake-server tests.
+ go-experimental:
+ name: go (experimental adapters)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: build, vet and test with -tags experimental_adapters
+ run: make experimental
+
postgres:
name: postgres conformance
runs-on: ubuntu-latest
@@ -90,7 +103,10 @@ jobs:
sdk-python:
name: sdk (python ${{ matrix.python }})
- runs-on: ubuntu-latest
+ # ubuntu-latest moved to Ubuntu 26.04, which has no Python 3.9 build for
+ # setup-python. The SDK still declares requires-python >= 3.9, so keep
+ # testing it on the last image that provides it.
+ runs-on: ubuntu-24.04
strategy:
matrix:
python: ["3.9", "3.13"]
@@ -177,7 +193,7 @@ jobs:
# again. It approves what CI checked, not what a reviewer read.
approve:
name: approve when CI passes
- needs: [go, postgres, sdk-typescript, console, sdk-python, helm, images, dco, release-source]
+ needs: [go, go-experimental, postgres, sdk-typescript, console, sdk-python, helm, images, dco, release-source]
if: >-
always() &&
github.event_name == 'pull_request' &&
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d4a2112..b36400d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,23 @@ permissions:
attestations: write
jobs:
+ # Nothing is published from a tag whose code does not pass the checks CI
+ # runs: a tag can be pushed on any commit, checked or not.
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: gofmt, vet and tests (default and experimental builds)
+ run: make fmt-check vet test experimental
+ - uses: azure/setup-helm@v4
+ - name: helm lint
+ run: make helm-lint
+
binaries:
+ needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -66,6 +82,7 @@ jobs:
dist/differently.spdx.json
images:
+ needs: verify
runs-on: ubuntu-latest
strategy:
matrix:
diff --git a/AGENTS.md b/AGENTS.md
index 54a9a88..f36e129 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,9 +11,11 @@ for humans).
- Never push to or open feature PRs against `main`; releases reach `main` only
through a release PR (see CONTRIBUTING.md).
- When CI is green you may merge your own PR into `pre-release`
- (`gh pr merge --squash`), or queue it with `--auto`. CI's `approve when CI
- passes` job approves same-repository PRs once every job is green; that
- approval is what `main`'s ruleset needs for release PRs.
+ (`gh pr merge --squash`). `pre-release` has no required checks, so
+ `--auto` cannot wait for CI there: check the run, then merge. CI's
+ `approve when CI passes` job approves same-repository PRs once every job
+ is green; that approval is what `main`'s ruleset needs for release and
+ sync PRs ([releasing](docs/maintaining.md#releasing)).
- One focused outcome per PR, Conventional Commit title, `git commit -s`.
## Before you push
@@ -22,9 +24,13 @@ for humans).
make verify
```
-It runs gofmt, go vet, Go tests with `-race`, the SDK tests and the Helm lint,
-including the replay of recorded OpenSandbox runtimes. For changes touching
-`internal/store/postgres`, also run `make test-postgres`. For anything a real
+It runs gofmt, go vet, Go tests with `-race`, the same build, vet and tests
+with `-tags experimental_adapters` (`make experimental`), the SDK tests and
+the Helm lint, including the replay of recorded OpenSandbox runtimes. For changes touching
+`internal/store/postgres`, also run `make test-postgres`. For `console/`, run
+`npm run typecheck && npm run build && npm run build:server` there; for
+`site/` or any page it publishes from `docs/`, `npm run build` in `site/`
+(it fails on a broken link). For anything a real
backend or cluster could break, pick the cheapest rung of the verification
ladder in [docs/maintaining.md](docs/maintaining.md#prove-a-change-at-the-right-cost)
that covers it, and say in the PR what did not run.
@@ -32,19 +38,32 @@ that covers it, and say in the PR what did not run.
## Map
- Contracts: `pkg/api` (wire types and error codes), `pkg/adapter`
- (backend contract), `api/openapi.yaml` (HTTP).
+ (backend contract), `api/openapi.yaml` (HTTP). A new field or route also
+ goes into both SDKs (`sdk/typescript`, `sdk/python`) and, where people
+ see it, the dashboard and the console.
- Placement and security decisions: `internal/policy`, `internal/auth`,
`internal/store` (atomic admission/activation). Treat changes here as
security-sensitive and keep the tests in `internal/server/server_test.go`
(acceptance scenarios A–L) green.
- Backends: `internal/adapters/*` (`opensandbox` is the default private
runtime; Differently runs no sandboxes itself), `internal/wiring` (adapter
- factories), `internal/pools`. The real-stack suite is `test/e2e`
+ factories), `internal/pools`. The default build holds `opensandbox`,
+ `daytona`, `freestyle`, `remote`, `devhost` and `fake`. Every other
+ adapter, and the `cloudhttp`, `pyshell` and `shellcmd` helpers, carries
+ `//go:build experimental_adapters`: a plain `go build`, `go vet` or
+ `go test` does not see them, so use `make experimental` when you touch
+ them or anything they share (`httpx`, `pkg/adapter`). The real-stack suite is `test/e2e`
(`make e2e-prepare e2e-up e2e`, or against any install).
- Recorded runtimes: `internal/adapters/opensandbox/testdata/recordings`,
replayed by `TestReplayRecordings`; re-record with the live test after an
OpenSandbox upgrade or a new adapter call.
- Stored credentials: `internal/secrets` (sealed values, `secret:` refs).
+- Capacity: `internal/scheduler/capacity.go` (slots, resume queue,
+ preemption, lifecycle limits) and its model `internal/capsim`
+ ([capacity](docs/scheduling/capacity.md)).
+- Interfaces: `internal/dashboard` (the built-in dashboard), `console/` (the
+ separate admin app, static or on Node with sign-in), `site/` (the website;
+ its docs sync from `docs/**`).
- How to change, prove, re-record, optimise and release:
[docs/maintaining.md](docs/maintaining.md).
- Docs that must stay truthful: `docs/feature-parity.md`, `docs/roadmap.md`,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b87c818..4c98a61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,190 @@ All notable changes are recorded here. The format follows
## [Unreleased] — on `pre-release`
+## [0.1.0-alpha.3] — 2026-11-10
+
+Third alpha, published as a GitHub pre-release. Not for production. It is
+mostly a correctness release: a review of the code found ten defects in
+placement, the scheduler, the reconciler and the clients, and each is fixed
+with a test that fails on the old behaviour. The default build is cut to the
+adapters that have run against real backends, and the release workflow now
+runs the checks before it publishes.
+
+What has not run for this release: the end-to-end suite and the live adapter
+tests. They last ran on the code before these changes (see `Verified` under
+0.1.0-alpha.2 and the sections below). The fixes are covered by in-process
+tests and, for the store, by the Postgres conformance job in CI.
+
+Upgrading from 0.1.0-alpha.2:
+
+- A config file or API call that names one of the 13 experimental adapter
+ kinds is refused by the published image (first section below).
+- A client that lists sandboxes by status needs to add `pausing`.
+- `PATCH /v1/sandboxes/{id}` with `ttlSeconds: 0` answers 400; use `DELETE`.
+
+### Documentation and CI
+
+- The SDK READMEs install the SDKs from this repository. Neither is
+ published: `pip install differently` installs an unrelated project of that
+ name, and `npm install @differently/sdk` finds nothing. Their method
+ tables no longer list `admin.agents.list`, which does not exist.
+- `make vet` and CI compile and vet the end-to-end and live test files
+ (`-tags "e2e live"`), which no job runs, so they cannot stop compiling
+ unnoticed.
+- The release workflow runs gofmt, vet, the Go tests (default and
+ experimental builds) and the Helm lint before it publishes binaries, the
+ chart or images.
+- The chart's `appVersion` names this release, so installing the chart from
+ a checkout pulls an image that exists. It used to name `0.1.0`, which was
+ never published; the air-gapped and day-2 guides named the same tag.
+
+### Changed (breaking): the default build holds only the verified adapters
+
+The published image, the release binaries and `make build` now contain the
+`opensandbox`, `daytona` and `freestyle` adapters, the `remote` plugin
+protocol and the dev-mode `devhost` and `fake`. The other 13 adapter kinds
+are experimental and compile in only with the Go build tag
+`experimental_adapters`: `agent-sandbox`, `cubesandbox`, `e2b`, `boat`,
+`morph`, `vercel`, `fly`, `blaxel`, `aws-agentcore`, `azure-sandboxes`,
+`azure-sessions`, `gcp-sandboxes` and `northflank`. Apart from
+`agent-sandbox`, which ran live on a kind cluster, they have only been
+tested against fake servers. No adapter code was removed, and CI still
+builds, vets and tests it with the tag.
+
+- **If you use one of the 13 kinds**, the next published image will not
+ start with a config file that names it: the error names the pool, the
+ kind and the build tag. A pool of such a kind added earlier through the
+ API stays registered and reports `unavailable` with the same reason.
+ Build the server with `go build -tags experimental_adapters`, or the image
+ with `--build-arg GO_TAGS=experimental_adapters`
+ ([provider catalog](docs/providers/README.md#what-the-server-contains)).
+- `POST /v1/admin/pools` answers 400 `invalid_request` for these kinds on a
+ default build.
+- Helm: pools of these kinds are refused unless `server.experimentalAdapters`
+ is `true`, which you set together with a `server.image` built with the
+ tag.
+- The dashboard, the console and `differently admin pool add --help` list
+ only the default kinds. Experimental pools are added in the config file,
+ with the CLI or through the API.
+- `make verify` and CI gained `make experimental` (build, vet and test with
+ the tag). The live tests of `agent-sandbox` and `cubesandbox` now need
+ `-tags live,experimental_adapters`.
+
+### Fixed (placement, scheduler and clients)
+
+Found by reading the code, each with a test that fails on the old behaviour.
+None has run against a live backend.
+
+- Placement no longer treats a policy it could not read as a policy that
+ does not exist. During a database error, a sandbox whose organization sets
+ `external: never` or denies a pool could be placed with that scope left
+ out. The sandbox now stays queued, and create, the placement explanation
+ and `GET /v1/capabilities` answer 503 `provider_unavailable`.
+- The reconciler never deletes or adopts a backend instance that carries no
+ sandbox ID. The `daytona` and `freestyle` adapters check ownership
+ themselves instead of trusting the provider's label or metadata filter,
+ and Daytona's create reuses a sandbox only when its label carries the ID.
+ A provider that ignored the filter would have had every sandbox in the
+ account deleted as an orphan. The other provider adapters still rely on
+ their backend's filter for the installation match.
+- A negative `ttlSeconds` on create is rejected; it used to pass the
+ `maxTTLSeconds` quota and give a sandbox with no expiry. A `PATCH` must set
+ a positive `ttlSeconds`: 0 used to delete the sandbox within a second and
+ now answers 400, so a client that ended sandboxes that way needs `DELETE`.
+- A pool that stops answering no longer slows placement on the others. Pool
+ probes and the reconciler ran on the scheduler loop and held it for their
+ timeouts (5 s per probe, 30 s per list); they now run beside it.
+- A delete or a TTL extension that lands while the scheduler is deciding now
+ wins. Expiry, the reconciler's "failed" and crash adoption wrote the status
+ without reading the record again, so an extended sandbox could still be
+ expired and a deleted one could come back as running.
+- OpenSandbox, Daytona and Freestyle are given the TTL plus five minutes as
+ their own deletion deadline. Theirs counts from the create request and
+ Differently's from the moment the sandbox is ready, so the backend deleted
+ first and the sandbox ended as failed instead of expired.
+- A pool keeps its working adapter when its credential cannot be read for a
+ moment. One failed read used to leave the pool without an adapter until a
+ later refresh. A deleted secret or file still takes the pool down, and a
+ changed value still rebuilds the adapter.
+- The CLI uses the dev server's operator token only for `localhost`,
+ `*.localhost` and loopback addresses. It used to send it to any URL
+ containing "localhost", and not to `127.0.0.1`.
+- A streamed exec through the Go client (`differently sandbox exec`) is no
+ longer cut off after ten minutes.
+- The audit views show the latest events. `differently usage audit`, the
+ dashboard and the console asked for the first page, which is the oldest
+ events, so anything after the first 100 or 200 never appeared.
+ `GET /v1/audit-events` takes `order=desc` (newest first) and `beforeSeq`;
+ the TypeScript and Python SDKs take `order` and `beforeSeq`/`before_seq`.
+- CI: the Python SDK job runs on `ubuntu-24.04`, the last runner image with
+ a Python 3.9 build; on `ubuntu-latest` it failed at setup.
+
+### Fixed (lifecycle)
+
+- A sandbox being paused reports the new status `pausing` instead of `running`
+ and refuses exec, file and port calls. A Fast Sandbox VM runs on for minutes
+ after its memory dump, and what it did then was lost on resume while every
+ call reported success. A resume during a pause calls it off on Fast Sandbox,
+ keeping the sandbox as it is; on other backends it waits for the pause. A
+ client that lists sandboxes by status needs to add `pausing`.
+- Idle preemption no longer pauses sandboxes in use. Without an idle timeout of
+ their own their activity was never recorded, so they all looked idle a minute
+ after starting, and a running command now counts as activity until it ends.
+
+### Added (capacity)
+
+- A paused sandbox on a pool that checkpoints on pause
+ (`lifecycle.pause-checkpoint`: OpenSandbox Fast Sandbox, Freestyle) gives
+ its slot back. Resuming takes one again; on a full pool the resume waits in
+ a queue (202) and runs when a slot frees.
+- Pool preemption (`preemption.mode: idle | lru`) pauses running sandboxes to
+ make room for waiting work; `preemptible: false` opts a sandbox out.
+- Lifecycle limits `maxRunSeconds` and `maxPausedSeconds` (request, `PATCH`
+ and operator defaults).
+- Capacity metrics (`differently_pool_slots`, `differently_resume_wait_seconds`,
+ `differently_preemptions_total` and more) and a capacity model,
+ `cmd/differently-capsim`, with results in `docs/scheduling/capacity.md`.
+- The TypeScript and Python SDKs take `maxRunSeconds`, `maxPausedSeconds`
+ and `preemptible` on create and update, and return `pausedAt`,
+ `slotReleased`, `resumeRequestedAt`, and a pool's `preemption` and
+ `released`. The console marks queued resumes, released slots and idle
+ sandboxes, and the Providers page counts paused sandboxes that hold no slot.
+- `differently_pause_seconds{pool}`: how long each pause Differently asks for
+ takes on its backend, the time a checkpointing pool needs to free a slot.
+- `differently-capsim -pause` and `-resume`; the Workbot model's pause is the
+ measured 182 s.
+
+### Documentation
+
+- `docs/research/fast-sandbox-pause-time.md`: where a Workbot desktop's
+ pause on Fast Sandbox spends its time (memory dump, a checksum pass that
+ reads the whole checkpoint back, a single-stream upload, all limited by
+ the state-root disk), what was measured before and after leaving templates
+ sparse (median 216 s → 180 s, resumes unchanged, state kept), ranked
+ candidates, and two Fast Sandbox patches (sparse pull, parallel multipart
+ upload). The pools guide gains "Shortening pauses"; `test/pause` holds the
+ harness that measured it.
+- `docs/research/fast-sandbox-shared-image-cache.patch`: stops Fast Sandbox's
+ image cache GC deleting the checkpoints paused sandboxes resume from, which
+ turned a 2 s resume into two minutes.
+- `docs/deploy/azure-vms.md`: a verified install on two Azure VMs with Fast
+ Sandbox microVMs, managed Postgres, ACR and Key Vault, serving OpenWork's
+ Workbot computers, and what it took (guest kernel, template registry,
+ Key Vault CSI on VMs, ACR token scopes, sandbox network policy).
+- The OpenWork guide records the end-to-end run, Freestyle template slugs
+ and OpenSandbox's maximum timeout; the e2e suite runs on installs with
+ several pools and replicas, and gives Fast Sandbox checkpoints time.
+- The README, roadmap, feature parity, end-to-end and keys pages, and the
+ website now state the same verified results: the end-to-end suite on a
+ Fast Sandbox pool, two-node k3s, and stored provider keys in use on Azure.
+ The website lists capacity and the admin console, and its sidebar gains
+ Capacity, Keys and providers, OpenWork and Maintaining.
+- `AGENTS.md` and `docs/maintaining.md` record what building and releasing
+ taught so far: where capacity, the console and the website live and how to
+ check them, the lists a new provider kind touches, new traps (Fast Sandbox
+ clock and checkpoints, template builds, zero times, CI on conflicting PRs),
+ and how release tags and `main` syncs are done.
+
### Added (admin console)
- `console/`: an admin app built with Astro, separate from the server. It has
@@ -402,6 +586,7 @@ OpenSandbox server on its Docker runtime.
beyond unit and in-process API tests: no live provider account, real
cluster or multi-replica run yet.
-[Unreleased]: https://github.com/different-ai/differently/compare/v0.1.0-alpha.2...pre-release
+[Unreleased]: https://github.com/different-ai/differently/compare/v0.1.0-alpha.3...pre-release
+[0.1.0-alpha.3]: https://github.com/different-ai/differently/releases/tag/v0.1.0-alpha.3
[0.1.0-alpha.2]: https://github.com/different-ai/differently/releases/tag/v0.1.0-alpha.2
[0.1.0-alpha.1]: https://github.com/different-ai/differently/releases/tag/v0.1.0-alpha.1
diff --git a/Makefile b/Makefile
index c08dfbb..2cd6bd4 100644
--- a/Makefile
+++ b/Makefile
@@ -4,29 +4,45 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo 0.1.
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
LDFLAGS := -s -w -X github.com/different-ai/differently/internal/version.Version=$(VERSION) -X github.com/different-ai/differently/internal/version.Commit=$(COMMIT)
BINS := differently-server differently
+# Go build tags for `make build`, `build-linux` and `images`. The default (none)
+# is the published build: opensandbox, daytona, freestyle, remote and devhost.
+# GO_TAGS=experimental_adapters adds the other adapter kinds (docs/providers).
+GO_TAGS ?=
+EXPERIMENTAL_TAG := experimental_adapters
PG_PORT ?= 55432
-.PHONY: build build-linux test test-postgres fmt-check vet sdk-test helm-lint verify dev images clean
+.PHONY: build build-linux test test-postgres fmt-check vet experimental sdk-test helm-lint verify dev images clean
build:
@mkdir -p bin
- CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/ $(addprefix ./cmd/,$(BINS))
+ CGO_ENABLED=0 go build -trimpath -tags "$(GO_TAGS)" -ldflags "$(LDFLAGS)" -o bin/ $(addprefix ./cmd/,$(BINS))
build-linux:
@for arch in amd64 arm64; do \
mkdir -p bin/linux-$$arch; \
- CGO_ENABLED=0 GOOS=linux GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" -o bin/linux-$$arch/ $(addprefix ./cmd/,$(BINS)) || exit 1; \
+ CGO_ENABLED=0 GOOS=linux GOARCH=$$arch go build -trimpath -tags "$(GO_TAGS)" -ldflags "$(LDFLAGS)" -o bin/linux-$$arch/ $(addprefix ./cmd/,$(BINS)) || exit 1; \
done
fmt-check:
@out=$$(gofmt -l cmd internal pkg api); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
+# The second pass compiles and vets the tests that need a real stack or a live
+# backend (test/e2e, the adapters' live tests). Nothing runs them here, so
+# without it they can stop compiling unnoticed.
vet:
go vet ./...
+ go vet -tags "e2e live" ./...
test:
go test -race -count=1 ./...
+# The adapter kinds outside the default build must keep compiling and passing
+# their fake-server tests: build, vet and test everything with the tag.
+experimental:
+ go build -tags $(EXPERIMENTAL_TAG) ./...
+ go vet -tags "$(EXPERIMENTAL_TAG) e2e live" ./...
+ go test -race -count=1 -tags $(EXPERIMENTAL_TAG) ./...
+
# Runs the store conformance suite against a disposable Postgres container.
test-postgres:
@docker rm -f differently-pg-test >/dev/null 2>&1 || true
@@ -46,13 +62,13 @@ helm-lint:
helm template differently deploy/helm/differently -f $$f >/dev/null || exit 1; \
done
-verify: fmt-check vet test sdk-test helm-lint
+verify: fmt-check vet test experimental sdk-test helm-lint
dev: build
./bin/differently-server --dev
images:
- docker build -f build/server.Dockerfile -t ghcr.io/different-ai/differently-server:$(VERSION) .
+ docker build -f build/server.Dockerfile --build-arg GO_TAGS=$(GO_TAGS) -t ghcr.io/different-ai/differently-server:$(VERSION) .
docker build -f build/cli.Dockerfile -t ghcr.io/different-ai/differently-cli:$(VERSION) .
clean:
diff --git a/README.md b/README.md
index d457be0..7afd4f1 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-
+
@@ -30,10 +30,12 @@ account of it:
- by default on [OpenSandbox](https://github.com/opensandbox-group/OpenSandbox)
on your own Docker hosts, Kubernetes clusters or microVM nodes;
-- on [15 other built-in provider adapters](docs/providers/README.md), from
- E2B, Daytona and Vercel to the AWS, Azure and Google Cloud sandbox
- services, or a plugin for any other backend, only where your policies
- allow it.
+- on Daytona or Freestyle, or a plugin for any other backend, only where
+ your policies allow it;
+- on 13 more [provider adapters](docs/providers/README.md#what-the-server-contains),
+ from E2B and Vercel to the AWS, Azure and Google Cloud sandbox services,
+ which are experimental: tested against fake servers and left out of the
+ published build unless you compile them in.
Apache-2.0, fully self-hosted, no telemetry. Built by the
[OpenWork](https://github.com/different-ai) team.
@@ -43,10 +45,11 @@ Apache-2.0, fully self-hosted, no telemetry. Built by the
▶ Watch the 36-second intro
-> **Pre-release ([`v0.1.0-alpha.2`](https://github.com/different-ai/differently/releases)).**
+> **Pre-release ([`v0.1.0-alpha.3`](https://github.com/different-ai/differently/releases)).**
> The whole platform is implemented and tested end to end against real
-> OpenSandbox servers (Docker and Kubernetes runtimes). It isn't ready for
-> production workloads yet: see [what is verified](#status).
+> OpenSandbox servers (Docker, Kubernetes and Fast Sandbox microVM
+> runtimes). It isn't ready for production workloads yet: see
+> [what is verified](#status).
## Why
@@ -67,7 +70,8 @@ that layer.
| **Multi-tenant from day one** | Organizations, projects, scoped API keys with rotation, quotas and fair queueing. |
| **Provable** | A tamper-evident audit chain, a placement explanation for every sandbox, and an operator trust report. |
| **Keys stay with you** | Provider keys are stored sealed or by reference. Switch where new sandboxes go with one command. |
-| **Self-hosted, all of it** | API, dashboard, scheduler, queue, audit and metrics run on your infrastructure. No phone-home. |
+| **Capacity that follows use** | Paused microVMs give their pool slot back, resumes queue on a full pool, and preemption and run limits keep pools from filling up. |
+| **Self-hosted, all of it** | API, dashboard and admin console, scheduler, queue, audit and metrics run on your infrastructure. No phone-home. |
## Get started
@@ -92,7 +96,7 @@ Differently and a real OpenSandbox server, as the end-to-end suite runs it:
```bash
make e2e-prepare e2e-up # API on :18090, OpenSandbox on :18091
-make e2e # optional: the 12 end-to-end tests
+make e2e # optional: the end-to-end tests
```
**3. Deploy it for your team.** On [one Linux VM on any cloud](docs/deploy/single-vm.md)
@@ -101,13 +105,15 @@ make e2e # optional: the 12 end-to-end tests
images from the [latest release](https://github.com/different-ai/differently/releases).
Then call it from code ([TypeScript](sdk/typescript), [Python](sdk/python),
-[Go](pkg/client)):
+[Go](pkg/client)). The SDKs are not on npm or PyPI yet; each README says how
+to install it from this repository.
```ts
import { Differently } from "@differently/sdk";
const differently = new Differently({ apiKey: process.env.DIFFERENTLY_API_KEY });
const sandbox = await differently.sandboxes.create({
+ // projectId: "prj_default", // needed with an operator key, such as the dev token
profile: "small",
placement: { strategy: "private-first", external: "never" },
});
@@ -143,22 +149,27 @@ prerequisites, [day-2 operations](docs/deploy/day-2.md) and the
- the published `v0.1.0-alpha.1` chart and images install from public
artifacts alone;
- real OpenSandbox responses are recorded and replayed in every `go test`;
-- the `agent-sandbox` adapter against a Kubernetes cluster (kind);
+- the experimental `agent-sandbox` adapter against a Kubernetes cluster
+ (kind);
- the Daytona (container class) and Freestyle adapters against live
accounts: exec, files, port URLs, stop/start and snapshots on both, and
egress policy, pause/resume, memory snapshots and fork on Freestyle;
- [OpenWork](https://github.com/different-ai/openwork)'s Workbot computers run
- through Differently as a sandbox gateway, behind a flag;
-- Fast Sandbox microVMs on one node: the adapter scenario (recorded), and
+ through Differently as a sandbox gateway, behind a flag, desktops on Fast
+ Sandbox microVMs included, driven by OpenWork's runner;
+- Fast Sandbox microVMs on Azure (two-node k3s with one KVM node): the
+ adapter scenario (recorded), the end-to-end suite on the microVM pool, and
Workbot's desktop streamed over a port URL, paused and resumed with its
memory, idled and woken by a request (pause takes minutes).
**Not verified yet:**
-- the end-to-end suite on Fast Sandbox microVMs;
-- multi-node clusters;
+- node drains, and more than one KVM node;
- managed EKS, GKE and AKS;
- the other 12 provider adapters against live accounts: each is tested
- against a fake of its provider's published API.
+ against a fake of its provider's published API. These 12 and
+ `agent-sandbox` are experimental and not in the published image or
+ binaries; a server built with `-tags experimental_adapters` has them
+ ([what the server contains](docs/providers/README.md#what-the-server-contains)).
Each of the 26 providers has a page with its tested status, capabilities and
configuration in the [provider catalog](docs/providers/README.md). The
@@ -176,8 +187,11 @@ against global, organization and project policy, ranks what is left, and
records why. Adapters talk to the backends:
- `opensandbox`, the default private runtime;
-- 15 more provider adapters, from `agent-sandbox` and `cubesandbox` you run
- to `e2b`, `daytona`, `vercel` and the cloud providers' sandbox services
+- `daytona` and `freestyle`, managed providers that receive work only when
+ policy allows it;
+- 13 experimental adapters behind the `experimental_adapters` build tag,
+ from `agent-sandbox` and `cubesandbox` you run to `e2b`, `vercel` and the
+ cloud providers' sandbox services
([provider catalog](docs/providers/README.md));
- `remote`, any service speaking the [adapter protocol](docs/adapters/specification.md).
@@ -217,6 +231,7 @@ under a control plane you run. See the
| `internal/` | Server, scheduler, reconciler, policy, store (memory and Postgres), auth, audit, secrets, adapters |
| `api/openapi.yaml` | The HTTP API contract (a test keeps it in sync with the router) |
| `sdk/typescript`, `sdk/python` | Official SDKs |
+| `console/` | The admin console: static files for any host, or Node with sign-in |
| `deploy/` | Helm chart (control plane only), Terraform (AWS) |
| `docs/` | Guides, deployment, architecture, security, roadmap |
| `site/` | The website and docs site |
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 2d961ae..659d2cc 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -152,10 +152,10 @@ paths:
post: { tags: [Lifecycle], summary: Start a stopped sandbox from its disk, responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } }
/v1/sandboxes/{id}/pause:
parameters: [{ $ref: "#/components/parameters/SandboxID" }]
- post: { tags: [Lifecycle], summary: Pause (freeze vCPUs; memory stays resident), responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } }
+ post: { tags: [Lifecycle], summary: "Pause (freeze vCPUs; memory stays resident). The sandbox is pausing, and refuses exec, file and port calls, until this finishes; on a sandbox already pausing, 202 with the sandbox; wait for paused", responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "202": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } }
/v1/sandboxes/{id}/resume:
parameters: [{ $ref: "#/components/parameters/SandboxID" }]
- post: { tags: [Lifecycle], summary: Resume a paused sandbox, responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } }
+ post: { tags: [Lifecycle], summary: "Resume a paused sandbox. During a pause, this calls it off where the backend can and keeps the sandbox as it is; elsewhere it is queued. On a full pool whose pause released the slot, the resume is queued. Queued: 202 with the sandbox still pausing or paused; wait for running", responses: { "200": { $ref: "#/components/responses/SandboxOK" }, "202": { $ref: "#/components/responses/SandboxOK" }, "501": { $ref: "#/components/responses/Error" } } }
/v1/sandboxes/{id}/snapshots:
parameters: [{ $ref: "#/components/parameters/SandboxID" }]
post:
@@ -247,8 +247,14 @@ paths:
get:
tags: [Usage and audit]
summary: List audit events in sequence order (audit:read)
+ description: >-
+ Oldest first by default: pass the page's nextCursor as afterSeq for the
+ next page. With order=desc the newest events come first, and nextCursor
+ is the beforeSeq of the next, older page.
parameters:
- - { name: afterSeq, in: query, schema: { type: integer } }
+ - { name: afterSeq, in: query, schema: { type: integer }, description: Only events after this sequence number }
+ - { name: beforeSeq, in: query, schema: { type: integer }, description: Only events before this sequence number }
+ - { name: order, in: query, schema: { type: string, enum: [asc, desc], default: asc }, description: desc returns the newest events first }
- { name: resource, in: query, schema: { type: string } }
- { name: action, in: query, schema: { type: string } }
- { $ref: "#/components/parameters/Limit" }
@@ -375,7 +381,7 @@ components:
properties: { keyId: { type: string }, orgId: { type: string }, projectId: { type: string }, scopes: { type: array, items: { type: string } }, operator: { type: boolean } }
SandboxStatus:
type: string
- enum: [queued, provisioning, running, stopped, paused, terminating, terminated, failed]
+ enum: [queued, provisioning, running, stopped, pausing, paused, terminating, terminated, failed]
Resources:
type: object
properties: { cpu: { type: integer, minimum: 1 }, memoryMiB: { type: integer, minimum: 128 }, diskGiB: { type: integer, minimum: 0 } }
@@ -400,15 +406,18 @@ components:
capabilities: { type: array, items: { $ref: "#/components/schemas/Capability" } }
classification: { type: string, description: "Operator-defined, e.g. private-only, internal, public" }
placement: { $ref: "#/components/schemas/Placement" }
- ttlSeconds: { type: integer }
+ ttlSeconds: { type: integer, minimum: 0, description: "Delete the sandbox this long after it starts. 0 uses the operator default; a negative value is rejected" }
env: { type: object, additionalProperties: { type: string }, description: Write-only; never returned }
labels: { type: object, additionalProperties: { type: string } }
priority: { type: integer }
fromSnapshot: { type: string }
idleTimeoutSeconds: { type: integer, description: "Stop or pause after this many seconds with no exec, file, port or keepalive call (at least 60); the next such call wakes it. 0 uses the operator default, -1 turns it off" }
+ maxRunSeconds: { type: integer, description: "Pause (or stop) after running this long without a break, busy or not (at least 60); the next call resumes it. 0 uses the operator default, -1 turns it off" }
+ maxPausedSeconds: { type: integer, description: "Delete after staying paused or stopped this long (at least 60). 0 uses the operator default, -1 turns it off" }
+ preemptible: { type: boolean, description: "false keeps the scheduler from pausing this sandbox to make room on a full pool. Default true" }
UpdateSandboxRequest:
type: object
- properties: { labels: { type: object, additionalProperties: { type: string } }, ttlSeconds: { type: integer }, idleTimeoutSeconds: { type: integer } }
+ properties: { labels: { type: object, additionalProperties: { type: string } }, ttlSeconds: { type: integer, minimum: 1, description: "New lifetime, counted from now; must be positive (delete the sandbox to end it now)" }, idleTimeoutSeconds: { type: integer }, maxRunSeconds: { type: integer }, maxPausedSeconds: { type: integer }, preemptible: { type: boolean } }
Sandbox:
type: object
required: [id, orgId, projectId, status, resources, classification, createdAt]
@@ -434,8 +443,14 @@ components:
ttlSeconds: { type: integer }
expiresAt: { type: string, format: date-time }
idleTimeoutSeconds: { type: integer }
+ maxRunSeconds: { type: integer }
+ maxPausedSeconds: { type: integer }
+ preemptible: { type: boolean }
+ pausedAt: { type: string, format: date-time }
+ slotReleased: { type: boolean, description: "A paused sandbox on a pool that checkpoints on pause holds no pool slot until it resumes" }
+ resumeRequestedAt: { type: string, format: date-time, description: Set while a resume waits for a free slot or for a pause to finish }
lastActivityAt: { type: string, format: date-time }
- idledAt: { type: string, format: date-time, description: Set while stopped or paused for being idle; the next exec, file, port or keepalive call wakes it }
+ idledAt: { type: string, format: date-time, description: Set while stopped, pausing or paused for being idle; the next exec, file, port or keepalive call wakes it }
fromSnapshot: { type: string }
attempts: { type: integer }
createdBy: { type: string }
diff --git a/build/server.Dockerfile b/build/server.Dockerfile
index f9e8bc8..10efb14 100644
--- a/build/server.Dockerfile
+++ b/build/server.Dockerfile
@@ -1,11 +1,14 @@
# differently-server image: control plane plus the CLI (used by `helm test`).
+# The published image is built with no Go build tags. To include the
+# experimental adapter kinds (docs/providers/README.md):
+# docker build -f build/server.Dockerfile --build-arg GO_TAGS=experimental_adapters .
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build
-ARG TARGETOS TARGETARCH VERSION=0.1.0-dev COMMIT=unknown
+ARG TARGETOS TARGETARCH VERSION=0.1.0-dev COMMIT=unknown GO_TAGS=""
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
-RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \
+RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -tags "${GO_TAGS}" \
-ldflags "-s -w -X github.com/different-ai/differently/internal/version.Version=${VERSION} -X github.com/different-ai/differently/internal/version.Commit=${COMMIT}" \
-o /out/ ./cmd/differently-server ./cmd/differently
diff --git a/cmd/differently-capsim/main.go b/cmd/differently-capsim/main.go
new file mode 100644
index 0000000..d7f8377
--- /dev/null
+++ b/cmd/differently-capsim/main.go
@@ -0,0 +1,40 @@
+// Command differently-capsim prints how a pool's capacity strategies compare
+// for Workbot-like users: see internal/capsim.
+//
+// go run ./cmd/differently-capsim -slots 4 -users 4,6,8,12,16,24
+// go run ./cmd/differently-capsim -slots 4 -users 12 -pause 90
+package main
+
+import (
+ "flag"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/different-ai/differently/internal/capsim"
+)
+
+func main() {
+ slots := flag.Int("slots", 4, "pool slots (maxConcurrency)")
+ users := flag.String("users", "4,6,8,12,16,24", "comma-separated user counts")
+ days := flag.Int("days", 5, "days to simulate")
+ seeds := flag.Int("seeds", 5, "runs per row, averaged")
+ pause := flag.Int("pause", capsim.Workbot(capsim.Hold, 0, 0).PauseTime, "seconds a pause takes to free its slot")
+ resume := flag.Int("resume", capsim.Workbot(capsim.Hold, 0, 0).ResumeTime, "seconds a resume takes once it holds a slot")
+ flag.Parse()
+ fmt.Println("| users | strategy | users on the pool | external computer-hours | slot utilization | wait p50 | wait p95 | preemptions/day | interrupted sessions/day |")
+ fmt.Println("|---:|---|---:|---:|---:|---:|---:|---:|---:|")
+ for _, u := range strings.Split(*users, ",") {
+ n, err := strconv.Atoi(strings.TrimSpace(u))
+ if err != nil {
+ panic(err)
+ }
+ for _, s := range []capsim.Strategy{capsim.Hold, capsim.Release, capsim.PreemptIdle, capsim.PreemptLRU} {
+ cfg := capsim.Workbot(s, *slots, n)
+ cfg.Days, cfg.PauseTime, cfg.ResumeTime = *days, *pause, *resume
+ r := capsim.Average(cfg, *seeds)
+ fmt.Printf("| %d | %s | %.0f%% | %.0f | %.0f%% | %.0f s | %.0f s | %.1f | %.1f |\n", n, s,
+ 100*r.PrivateUsers, r.ExternalHours, 100*r.Utilization, r.WaitP50, r.WaitP95, r.PreemptionsPerDay, r.InterruptionsPerDay)
+ }
+ }
+}
diff --git a/cmd/differently/main.go b/cmd/differently/main.go
index c8a71f8..8844827 100644
--- a/cmd/differently/main.go
+++ b/cmd/differently/main.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
+ "net"
"net/url"
"os"
"os/signal"
@@ -58,8 +59,8 @@ func load() settings {
if s.URL == "" {
s.URL = "http://localhost:8080"
}
- // Local dev servers write an operator token; use it for localhost.
- if s.APIKey == "" && strings.Contains(s.URL, "localhost") {
+ // Local dev servers write an operator token; use it for this machine only.
+ if s.APIKey == "" && isLoopbackURL(s.URL) {
home, _ := os.UserHomeDir()
if b, err := os.ReadFile(filepath.Join(home, ".differently", "dev", "token")); err == nil {
s.APIKey = strings.TrimSpace(string(b))
@@ -71,6 +72,23 @@ func load() settings {
return s
}
+// isLoopbackURL reports whether raw points at this machine: localhost, a
+// *.localhost name or a loopback address. The dev operator token is sent
+// nowhere else; a host that merely contains "localhost" (for example
+// localhost.example.com) is someone else's server.
+func isLoopbackURL(raw string) bool {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return false
+ }
+ host := strings.ToLower(u.Hostname())
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
func cli() *client.Client {
s := load()
return client.New(s.URL, s.APIKey)
@@ -614,7 +632,7 @@ func poolAddCmd() *cobra.Command {
return raw("POST", "/v1/admin/pools", body)
}}
f := cmd.Flags()
- f.StringVar(&adapterKind, "adapter", "", "opensandbox, daytona, freestyle, remote, vercel, fly, agent-sandbox, cubesandbox, aws-agentcore, azure-sandboxes, azure-sessions, gcp-sandboxes, northflank, boat, e2b, morph or blaxel")
+ f.StringVar(&adapterKind, "adapter", "", "opensandbox, daytona, freestyle or remote; a server built with -tags experimental_adapters has more (docs/providers)")
f.StringVar(&trust, "trust", "", "external (someone else runs it) or private (you do); managed providers are always external, self-hostable kinds must set one")
f.StringArrayVar(&settings, "config", nil, "adapter setting key=value (repeatable), e.g. apiURL=https://app.daytona.io/api")
f.StringVar(®ion, "region", "", "region label")
@@ -654,7 +672,7 @@ func usageCmd() *cobra.Command {
cmd.Flags().String("group-by", "pool", "pool, project, org or trust")
cmd.AddCommand(
&cobra.Command{Use: "quota", Short: "Show quota and current consumption", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/quotas", nil) }},
- &cobra.Command{Use: "audit", Short: "List audit events", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/audit-events?limit=100", nil) }},
+ &cobra.Command{Use: "audit", Short: "List the latest audit events, newest first", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/audit-events?limit=100&order=desc", nil) }},
)
return cmd
}
diff --git a/cmd/differently/main_test.go b/cmd/differently/main_test.go
index f410185..a71454f 100644
--- a/cmd/differently/main_test.go
+++ b/cmd/differently/main_test.go
@@ -29,3 +29,25 @@ func TestReportErrorNeverSilent(t *testing.T) {
})
}
}
+
+// The dev operator token goes to this machine only.
+func TestDevTokenOnlyForLoopback(t *testing.T) {
+ for raw, want := range map[string]bool{
+ "http://localhost:8080": true,
+ "http://LOCALHOST:8080/": true,
+ "http://api.localhost:8080": true,
+ "http://127.0.0.1:18180": true,
+ "http://[::1]:8080": true,
+ "https://localhost.example.com": false,
+ "https://differently.example.com": false,
+ "https://example.com/localhost": false,
+ "https://example.com/?next=localhost": false,
+ "https://notlocalhost": false,
+ "http://10.0.0.5:8080": false,
+ "://bad": false,
+ } {
+ if got := isLoopbackURL(raw); got != want {
+ t.Errorf("isLoopbackURL(%q) = %v, want %v", raw, got, want)
+ }
+ }
+}
diff --git a/console/src/lib/providers.ts b/console/src/lib/providers.ts
index 5eb0823..45a198b 100644
--- a/console/src/lib/providers.ts
+++ b/console/src/lib/providers.ts
@@ -1,6 +1,9 @@
-// Provider kinds the console can add, with their main config fields. Kept in
-// step with the built-in dashboard (internal/dashboard/static/app.js); the full
-// list of keys per provider is in docs/providers/.
+// Provider kinds the console can add, with their main config fields: the kinds
+// in the default server build. Experimental kinds (a server built with
+// -tags experimental_adapters) are added with the CLI, the API or the config
+// file. Kept in step with the built-in dashboard
+// (internal/dashboard/static/app.js); the full list of keys per provider is in
+// docs/providers/.
export interface ProviderField { key: string; label: string; choices?: string[]; required?: boolean }
export interface ProviderKind { kind: string; label: string; trust: "external" | null; fields: ProviderField[] }
@@ -59,141 +62,6 @@ export const providerKinds: ProviderKind[] = [
}
]
},
- {
- "kind": "boat",
- "label": "Boat",
- "trust": "external",
- "fields": [
- {
- "key": "apiURL",
- "label": "API URL (default https://boat.dev/api/v1)"
- },
- {
- "key": "type",
- "label": "smallest size",
- "choices": [
- "small",
- "default",
- "large",
- "xlarge"
- ]
- },
- {
- "key": "snapshot",
- "label": "saved snapshot"
- },
- {
- "key": "environment",
- "label": "environment"
- },
- {
- "key": "org",
- "label": "organization"
- },
- {
- "key": "autoStopSeconds",
- "label": "auto-stop seconds for sandboxes without a TTL"
- },
- {
- "key": "portAccess",
- "label": "port URLs",
- "choices": [
- "token",
- "public"
- ]
- }
- ]
- },
- {
- "kind": "e2b",
- "label": "E2B",
- "trust": null,
- "fields": [
- {
- "key": "apiURL",
- "label": "API URL (default https://api.e2b.app)"
- },
- {
- "key": "domain",
- "label": "domain (default e2b.app)"
- },
- {
- "key": "template",
- "label": "template (default base)"
- },
- {
- "key": "maxLifetimeSeconds",
- "label": "plan's maximum runtime in seconds (default 3600)"
- },
- {
- "key": "ports",
- "label": "ports",
- "choices": [
- "private",
- "public"
- ]
- },
- {
- "key": "egress",
- "label": "egress",
- "choices": [
- "public",
- "none"
- ]
- }
- ]
- },
- {
- "kind": "morph",
- "label": "Morph Cloud",
- "trust": "external",
- "fields": [
- {
- "key": "apiURL",
- "label": "API URL (default https://cloud.morph.so/api)"
- },
- {
- "key": "snapshot",
- "label": "snapshot"
- },
- {
- "key": "image",
- "label": "image (default morphvm-minimal)"
- },
- {
- "key": "portAuth",
- "label": "port auth",
- "choices": [
- "none",
- "api_key"
- ]
- }
- ]
- },
- {
- "kind": "blaxel",
- "label": "Blaxel",
- "trust": "external",
- "fields": [
- {
- "key": "workspace",
- "label": "workspace",
- "required": true
- },
- {
- "key": "apiURL",
- "label": "API URL (default https://api.blaxel.ai/v0)"
- },
- {
- "key": "region",
- "label": "region (e.g. us-pdx-1)"
- },
- {
- "key": "image",
- "label": "image (default blaxel/base-image:latest)"
- }
- ]
- },
{
"kind": "opensandbox",
"label": "OpenSandbox",
@@ -277,278 +145,5 @@ export const providerKinds: ProviderKind[] = [
"required": true
}
]
- },
- {
- "kind": "vercel",
- "label": "Vercel Sandbox",
- "trust": "external",
- "fields": [
- {
- "key": "project",
- "label": "project ID or name",
- "required": true
- },
- {
- "key": "team",
- "label": "team ID"
- },
- {
- "key": "region",
- "label": "region (default iad1)"
- },
- {
- "key": "image",
- "label": "image"
- },
- {
- "key": "architecture",
- "label": "architecture",
- "choices": [
- "amd64",
- "arm64"
- ]
- },
- {
- "key": "ports",
- "label": "ports (comma separated)"
- },
- {
- "key": "maxTimeoutSeconds",
- "label": "max session seconds (default 2700)"
- }
- ]
- },
- {
- "kind": "fly",
- "label": "Fly.io Machines",
- "trust": "external",
- "fields": [
- {
- "key": "app",
- "label": "Fly app name",
- "required": true
- },
- {
- "key": "region",
- "label": "region"
- },
- {
- "key": "image",
- "label": "image"
- },
- {
- "key": "cpuKind",
- "label": "CPU kind",
- "choices": [
- "shared",
- "performance"
- ]
- },
- {
- "key": "suspend",
- "label": "suspend for pause",
- "choices": [
- "true",
- "false"
- ]
- }
- ]
- },
- {
- "kind": "agent-sandbox",
- "label": "agent-sandbox (Kubernetes)",
- "trust": null,
- "fields": [
- {
- "key": "namespace",
- "label": "namespace (default default)"
- },
- {
- "key": "apiServer",
- "label": "API server URL (default: in-cluster)"
- },
- {
- "key": "caFile",
- "label": "API server CA file on the server"
- },
- {
- "key": "image",
- "label": "image (default ubuntu:24.04)"
- },
- {
- "key": "warmPool",
- "label": "warm pool (SandboxWarmPool name)"
- },
- {
- "key": "runtimeClassName",
- "label": "RuntimeClass (e.g. gvisor)"
- },
- {
- "key": "isolation",
- "label": "isolation",
- "choices": [
- "container",
- "microvm"
- ]
- },
- {
- "key": "volumeSize",
- "label": "volume size, e.g. 10Gi (enables stop/start)"
- }
- ]
- },
- {
- "kind": "cubesandbox",
- "label": "CubeSandbox",
- "trust": null,
- "fields": [
- {
- "key": "apiURL",
- "label": "CubeAPI URL",
- "required": true
- },
- {
- "key": "templateId",
- "label": "template id"
- },
- {
- "key": "sandboxURL",
- "label": "CubeProxy URL (header routing)"
- },
- {
- "key": "domain",
- "label": "sandbox domain (default: CubeAPI's)"
- },
- {
- "key": "proxyScheme",
- "label": "proxy scheme",
- "choices": [
- "https",
- "http"
- ]
- }
- ]
- },
- {
- "kind": "aws-agentcore",
- "label": "AWS AgentCore Code Interpreter",
- "trust": null,
- "fields": [
- {
- "key": "region",
- "label": "AWS region",
- "required": true
- },
- {
- "key": "codeInterpreter",
- "label": "code interpreter (default aws.codeinterpreter.v1)"
- }
- ]
- },
- {
- "kind": "azure-sandboxes",
- "label": "Azure Container Apps Sandboxes",
- "trust": null,
- "fields": [
- {
- "key": "endpoint",
- "label": "data-plane endpoint",
- "required": true
- },
- {
- "key": "subscriptionId",
- "label": "subscription ID",
- "required": true
- },
- {
- "key": "resourceGroup",
- "label": "resource group",
- "required": true
- },
- {
- "key": "sandboxGroup",
- "label": "sandbox group",
- "required": true
- },
- {
- "key": "diskImage",
- "label": "public disk image"
- },
- {
- "key": "egress",
- "label": "egress",
- "choices": [
- "none",
- "public"
- ]
- }
- ]
- },
- {
- "kind": "azure-sessions",
- "label": "Azure dynamic sessions (Python)",
- "trust": null,
- "fields": [
- {
- "key": "endpoint",
- "label": "pool management endpoint",
- "required": true
- },
- {
- "key": "outputLimit",
- "label": "output limit (default 1048576)"
- }
- ]
- },
- {
- "kind": "gcp-sandboxes",
- "label": "Google Cloud Agent Platform sandboxes",
- "trust": null,
- "fields": [
- {
- "key": "project",
- "label": "project ID",
- "required": true
- },
- {
- "key": "location",
- "label": "location",
- "required": true
- },
- {
- "key": "reasoningEngine",
- "label": "engine ID",
- "required": true
- },
- {
- "key": "template",
- "label": "shell template ID",
- "required": true
- }
- ]
- },
- {
- "kind": "northflank",
- "label": "Northflank",
- "trust": null,
- "fields": [
- {
- "key": "projectId",
- "label": "project ID",
- "required": true
- },
- {
- "key": "teamId",
- "label": "team ID (organization tokens)"
- },
- {
- "key": "image",
- "label": "image (default ubuntu:24.04)"
- },
- {
- "key": "plan",
- "label": "plan (default nf-compute-200)"
- }
- ]
}
];
diff --git a/console/src/lib/ui.ts b/console/src/lib/ui.ts
index a991b13..abcfc15 100644
--- a/console/src/lib/ui.ts
+++ b/console/src/lib/ui.ts
@@ -45,7 +45,7 @@ export function mount(target: string | Element, ...nodes: Child[]): void {
const tone: Record = {
running: 'ok', healthy: 'ok', active: 'ok', ok: 'ok', valid: 'ok', placed: 'ok',
- queued: 'warn', provisioning: 'warn', degraded: 'warn', draining: 'warn', terminating: 'warn',
+ queued: 'warn', provisioning: 'warn', degraded: 'warn', draining: 'warn', pausing: 'warn', terminating: 'warn',
failed: 'bad', unavailable: 'bad', revoked: 'bad', denied: 'bad', rejected: 'bad', invalid: 'bad',
};
export const badge = (status: string) => el('span', { class: `badge ${tone[status] ?? ''}` }, status.replace(/_/g, ' '));
diff --git a/console/src/pages/audit-log.astro b/console/src/pages/audit-log.astro
index 0a38feb..bed2ebf 100644
--- a/console/src/pages/audit-log.astro
+++ b/console/src/pages/audit-log.astro
@@ -15,7 +15,7 @@ import PageHeader from '../components/PageHeader.astro';
const c = client();
void section('#list', async () => {
- const page = await c.auditEvents.list({ limit: 200 });
+ const page = await c.auditEvents.list({ limit: 200, order: 'desc' });
const events = [...page.items].sort((a, b) => b.seq - a.seq);
return table([
{ label: 'Time', cell: (e) => el('span', { class: 'muted' }, date(e.time)) },
diff --git a/console/src/pages/index.astro b/console/src/pages/index.astro
index 20cf36b..e7402de 100644
--- a/console/src/pages/index.astro
+++ b/console/src/pages/index.astro
@@ -23,7 +23,7 @@ const base = import.meta.env.BASE_URL;
const { items } = await c.sandboxes.list({ limit: 500 });
const count = (...s: string[]) => items.filter((x) => s.includes(x.status)).length;
const stat = (label: string, value: number) => el('div', { class: 'stat' }, el('div', { class: 'label' }, label), el('div', { class: 'value' }, value));
- return [stat('Running', count('running')), stat('Queued', count('queued', 'provisioning')), stat('Stopped or paused', count('stopped', 'paused')), stat('Failed', count('failed'))];
+ return [stat('Running', count('running')), stat('Queued', count('queued', 'provisioning')), stat('Stopped or paused', count('stopped', 'pausing', 'paused')), stat('Failed', count('failed'))];
});
section('#pools', async () => {
const pools = await c.capabilities.list();
diff --git a/console/src/pages/providers.astro b/console/src/pages/providers.astro
index f848ef8..a1661d0 100644
--- a/console/src/pages/providers.astro
+++ b/console/src/pages/providers.astro
@@ -19,7 +19,7 @@ import { providerKinds } from '../lib/providers';
Provider
{providerKinds.map((k) => {k.label} )}
- Pool name
+ Pool name
Trust
External: someone else runs it Private: you run it
@@ -65,7 +65,7 @@ import { providerKinds } from '../lib/providers';
{ label: 'Adapter', cell: (p) => p.adapter },
{ label: 'Trust', cell: (p) => p.trust },
{ label: 'Health', cell: (p) => badge(!p.enabled ? 'disabled' : p.draining ? 'draining' : p.health?.status ?? 'unknown') },
- { label: 'Active', cell: (p) => p.active },
+ { label: 'Active', cell: (p) => p.released ? el('span', { title: 'Paused sandboxes that hold no slot until they resume' }, String(p.active), el('span', { class: 'muted' }, ` · ${p.released} paused, no slot`)) : p.active },
{ label: 'Source', cell: (p) => el('span', { class: 'muted' }, p.source === 'config' ? 'Config file' : 'Added here') },
{ label: '', className: 'actions', cell: (p) => rowMenu([
{ label: 'Use for new sandboxes', run: async () => { if (await confirmAction(`Use ${p.name}?`, 'Every other pool is drained, so new sandboxes go here. Running sandboxes stay where they are.', 'Use', false)) { await c.admin.pools.use(p.name, { approveExternal: p.trust === 'external' }); toast(`New sandboxes go to ${p.name}`); await load(); } } },
diff --git a/console/src/pages/sandboxes.astro b/console/src/pages/sandboxes.astro
index 56c095a..576e4e7 100644
--- a/console/src/pages/sandboxes.astro
+++ b/console/src/pages/sandboxes.astro
@@ -12,7 +12,7 @@ import Icon from '../components/Icon.astro';
All
Running
Queued
- Stopped
+ Stopped
Failed
@@ -41,6 +41,9 @@ import Icon from '../components/Icon.astro';
const c = client();
let status = '';
+ // Why a paused or stopped sandbox is waiting.
+ const note = (s: Sandbox) => (s.info.resumeRequestedAt ? 'Resume queued' : s.info.slotReleased ? 'Slot released' : s.info.idledAt ? 'Idle' : '');
+
async function load() {
await section('#list', async () => {
const filter = status ? (status.split(',') as SandboxStatus[]) : undefined;
@@ -48,7 +51,7 @@ import Icon from '../components/Icon.astro';
const act = (label: string, fn: () => Promise) => ({ label, run: async () => { await fn(); toast(`${label}: done`); await load(); } });
return table([
{ label: 'Sandbox', cell: (s) => el('span', { class: 'mono' }, s.id) },
- { label: 'Status', cell: (s) => badge(s.status) },
+ { label: 'Status', cell: (s) => el('span', { class: 'status-cell', title: s.info.statusReason }, badge(s.status), note(s) && el('span', { class: 'muted' }, note(s))) },
{ label: 'Pool', cell: (s) => s.info.pool || el('span', { class: 'muted' }, '—') },
{ label: 'Profile', cell: (s) => s.info.profile || s.info.template || '—' },
{ label: 'Created', cell: (s) => el('span', { class: 'muted' }, relTime(s.info.createdAt)) },
@@ -56,7 +59,8 @@ import Icon from '../components/Icon.astro';
{ ...act('Stop', () => s.stop()), hidden: s.status !== 'running' },
{ ...act('Start', () => s.start()), hidden: s.status !== 'stopped' },
{ ...act('Pause', () => s.pause()), hidden: s.status !== 'running' },
- { ...act('Resume', () => s.resume()), hidden: s.status !== 'paused' },
+ { label: 'Resume', hidden: (s.status !== 'paused' && s.status !== 'pausing') || !!s.info.resumeRequestedAt,
+ run: async () => { await s.resume(); toast(s.info.resumeRequestedAt ? 'Resume queued: it starts when the pool has a free slot' : 'Resume: done'); await load(); } },
{ label: 'Copy ID', run: () => navigator.clipboard.writeText(s.id).then(() => toast('Copied')) },
{ label: 'Delete', danger: true, hidden: s.status === 'terminated' || s.status === 'terminating',
run: async () => { if (await confirmAction('Delete sandbox?', `${s.id} and its disk are removed. This cannot be undone.`, 'Delete')) { await s.delete(); toast('Deleted'); await load(); } } },
diff --git a/console/src/styles/console.css b/console/src/styles/console.css
index 15b9126..1ecc9d5 100644
--- a/console/src/styles/console.css
+++ b/console/src/styles/console.css
@@ -215,6 +215,7 @@ dl.facts dd { margin: 0; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px 0; }
.row .title { font-weight: 500; }
.row .sub { color: var(--muted); font-size: 14px; }
+.status-cell { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
.segmented { display: inline-flex; padding: 3px; border: 1px solid var(--border); border-radius: 10px; gap: 2px; }
.segmented button { border: 0; background: transparent; padding: 5px 12px; border-radius: 7px; font-size: 13.5px; cursor: pointer; color: var(--muted); }
.segmented button[aria-pressed='true'] { background: var(--active); color: var(--text); font-weight: 500; }
diff --git a/deploy/helm/differently/Chart.yaml b/deploy/helm/differently/Chart.yaml
index ff2b2fa..c411d22 100644
--- a/deploy/helm/differently/Chart.yaml
+++ b/deploy/helm/differently/Chart.yaml
@@ -6,7 +6,7 @@ description: >-
OpenSandbox server, installed separately, as its default private pool.
type: application
version: 0.3.0
-appVersion: "0.1.0"
+appVersion: "0.1.0-alpha.3"
kubeVersion: ">=1.29.0-0"
home: https://github.com/different-ai/differently
sources:
diff --git a/deploy/helm/differently/ci/hybrid-daytona-values.yaml b/deploy/helm/differently/ci/hybrid-daytona-values.yaml
index 4639490..2dc72b5 100644
--- a/deploy/helm/differently/ci/hybrid-daytona-values.yaml
+++ b/deploy/helm/differently/ci/hybrid-daytona-values.yaml
@@ -4,6 +4,9 @@
config:
store:
driver: postgres
+ defaults:
+ maxRunSeconds: 28800
+ maxPausedSeconds: 1209600
policy:
external: overflow
externalPools: [daytona, freestyle]
@@ -16,6 +19,7 @@ config:
trust: external
maxConcurrency: 10
credentials: {secret: freestyle-key}
+ preemption: {mode: idle, idleSeconds: 60}
config:
egress: none
diff --git a/deploy/helm/differently/templates/_helpers.tpl b/deploy/helm/differently/templates/_helpers.tpl
index 0a783f3..5d0a722 100644
--- a/deploy/helm/differently/templates/_helpers.tpl
+++ b/deploy/helm/differently/templates/_helpers.tpl
@@ -397,6 +397,9 @@ Validation. Fails the render with an actionable message. The server's
{{- if eq .adapter "devhost" -}}
{{- fail (printf "Pool %q: the devhost adapter has no isolation and is not supported by this chart." .name) -}}
{{- end -}}
+{{- if and (has .adapter (list "vercel" "fly" "agent-sandbox" "cubesandbox" "aws-agentcore" "azure-sandboxes" "azure-sessions" "gcp-sandboxes" "northflank" "boat" "e2b" "morph" "blaxel")) (not $.Values.server.experimentalAdapters) -}}
+{{- fail (printf "Pool %q uses the experimental %s adapter, which the default differently-server image does not contain. Build an image with -tags experimental_adapters, point server.image at it and set server.experimentalAdapters=true." .name .adapter) -}}
+{{- end -}}
{{- if eq .adapter "opensandbox" -}}
{{- if not (has (.trust | default "") (list "private" "external")) -}}
{{- fail (printf "Pool %q uses the opensandbox adapter: set trust to private (you operate the server) or external (someone else does)." .name) -}}
diff --git a/deploy/helm/differently/values.schema.json b/deploy/helm/differently/values.schema.json
index 6c171d3..651bae4 100644
--- a/deploy/helm/differently/values.schema.json
+++ b/deploy/helm/differently/values.schema.json
@@ -62,13 +62,23 @@
"required": ["name", "adapter"],
"properties": {
"name": {"type": "string", "minLength": 1},
- "adapter": {"type": "string", "enum": ["opensandbox", "remote", "daytona", "freestyle", "fake", "vercel", "fly", "agent-sandbox", "cubesandbox", "aws-agentcore", "azure-sandboxes", "azure-sessions", "gcp-sandboxes", "northflank", "boat", "e2b", "morph", "blaxel"]},
+ "adapter": {"type": "string", "description": "opensandbox, remote, daytona and freestyle are in the default image; the other kinds need server.experimentalAdapters and an image built with -tags experimental_adapters.", "enum": ["opensandbox", "remote", "daytona", "freestyle", "fake", "vercel", "fly", "agent-sandbox", "cubesandbox", "aws-agentcore", "azure-sandboxes", "azure-sessions", "gcp-sandboxes", "northflank", "boat", "e2b", "morph", "blaxel"]},
"trust": {"type": "string", "enum": ["private", "external"]},
"region": {"type": "string"},
"labels": {"$ref": "#/definitions/stringMap"},
"enabled": {"type": "boolean"},
"maxConcurrency": {"$ref": "#/definitions/nonNegativeInt"},
"costModel": {"$ref": "#/definitions/costModel"},
+ "preemption": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "Pause running sandboxes to make room for waiting work (pools that checkpoint on pause only).",
+ "properties": {
+ "mode": {"type": "string", "enum": ["off", "idle", "lru"]},
+ "minRunSeconds": {"$ref": "#/definitions/nonNegativeInt"},
+ "idleSeconds": {"$ref": "#/definitions/nonNegativeInt"}
+ }
+ },
"config": {"$ref": "#/definitions/stringMap", "description": "Adapter settings; every value must be a string."},
"credentials": {
"type": "object",
@@ -242,7 +252,9 @@
"template": {"type": "string"},
"resources": {"$ref": "#/definitions/resources"},
"ttlSeconds": {"$ref": "#/definitions/nonNegativeInt"},
- "idleTimeoutSeconds": {"$ref": "#/definitions/nonNegativeInt"}
+ "idleTimeoutSeconds": {"$ref": "#/definitions/nonNegativeInt"},
+ "maxRunSeconds": {"$ref": "#/definitions/nonNegativeInt"},
+ "maxPausedSeconds": {"$ref": "#/definitions/nonNegativeInt"}
}
},
"classifications": {
@@ -425,6 +437,7 @@
"additionalProperties": false,
"properties": {
"image": {"$ref": "#/definitions/image"},
+ "experimentalAdapters": {"type": "boolean", "description": "Set when server.image was built with -tags experimental_adapters; allows pools of the experimental adapter kinds."},
"replicas": {"type": "integer", "minimum": 0},
"containerPort": {"type": "integer", "minimum": 1, "maximum": 65535},
"preflight": {"type": "object", "additionalProperties": false, "properties": {"enabled": {"type": "boolean"}}},
diff --git a/deploy/helm/differently/values.yaml b/deploy/helm/differently/values.yaml
index 1862942..668a648 100644
--- a/deploy/helm/differently/values.yaml
+++ b/deploy/helm/differently/values.yaml
@@ -394,6 +394,12 @@ server:
tag: ""
digest: ""
pullPolicy: IfNotPresent
+ # The default image holds the opensandbox, daytona, freestyle and remote
+ # adapters. Every other kind is experimental and needs an image built with
+ # `go build -tags experimental_adapters` (docs/providers/README.md): point
+ # server.image at that image and set this to true, or the chart refuses
+ # pools of those kinds.
+ experimentalAdapters: false
# Must be 1 with config.store.driver=memory.
replicas: 1
containerPort: 8080
diff --git a/docs/README.md b/docs/README.md
index adeb255..ca48549 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -24,6 +24,7 @@ two-minute local quickstart.
|---|---|
| [Deployment overview and support matrix](deploy/README.md) | Where to run it, and what is tested, documented or unverified |
| [One Linux VM on any cloud](deploy/single-vm.md) | The quickest team setup: k3s, OpenSandbox and Differently on Azure, AWS, Google Cloud or bare metal |
+| [Azure VMs serving OpenWork](deploy/azure-vms.md) | A verified two-VM install with Fast Sandbox microVMs, managed Postgres, a registry and Key Vault, and Freestyle and Daytona as external pools |
| [Prerequisites](deploy/prerequisites.md) | Cluster, Postgres and an OpenSandbox server (or another backend) |
| [Kubernetes (existing cluster)](deploy/kubernetes.md) | Helm install to first sandbox |
| [AWS](deploy/aws.md) · [GCP](deploy/gcp.md) · [Azure](deploy/azure.md) · [Bare metal](deploy/bare-metal.md) | Environment-specific notes |
@@ -38,11 +39,13 @@ two-minute local quickstart.
| [Feature parity](feature-parity.md) | Differently vs Boat, Daytona, Freestyle, E2B, OpenSandbox; backend capability matrix |
| [Competitive landscape](competitive-landscape.md) | Ownership models and where Differently fits |
| [OpenSandbox spike](research/opensandbox-spike.md) | What OpenSandbox offers as a backend or foundation, observed and read |
+| [Fast Sandbox pause time](research/fast-sandbox-pause-time.md) | Where a desktop's pause spends its time, what cut it, and the upstream patches for the rest |
| [Architecture RFC](architecture/README.md) | Layers, components, request lifecycle, state machine, HA |
| [Security and trust model](security/trust-model.md) | Threats, boundaries, enforced controls, residual risks |
| [Adapter specification](adapters/specification.md) | The backend contract and the HTTP plugin protocol |
| [Maintaining and improving Differently](maintaining.md) | For agents and maintainers: where to change what, the verification ladder, recorded runtimes, performance, traps |
| [Placement policy](scheduling/placement-policy.md) | Merge rules, strategies, overflow, quotas, fairness |
+| [Capacity](scheduling/capacity.md) | Pool slots, paused sandboxes that give theirs back, the resume queue, preemption and lifecycle limits, with a model of real usage |
| [Storage and lifecycle](storage-and-lifecycle.md) | Persistence, snapshots, fork, portability limits |
| [Reliability and performance](reliability-and-performance.md) | SLOs, benchmarks to run, chaos tests |
| [Acceptance scenarios](acceptance-scenarios.md) | Scenarios A–L and the tests that cover them |
diff --git a/docs/adapters/specification.md b/docs/adapters/specification.md
index f0d1a05..36f1a90 100644
--- a/docs/adapters/specification.md
+++ b/docs/adapters/specification.md
@@ -7,9 +7,10 @@
An **execution adapter** creates and manages sandbox instances on one backend.
The control plane holds one adapter per [pool](../architecture/README.md#4-the-three-layers).
-Adapters run in-process (16 provider adapters, listed in the
-[provider catalog](../providers/README.md), plus the development-only
-`devhost` and `fake`) or out of process behind the HTTP protocol in
+Adapters run in-process (`opensandbox`, `daytona` and `freestyle` in every
+build, 13 experimental provider adapters behind the `experimental_adapters`
+build tag, all listed in the [provider catalog](../providers/README.md),
+plus the development-only `devhost` and `fake`) or out of process behind the HTTP protocol in
`pkg/adapter/adapterhttp`, which `remote` pools use. The
default private runtime is an OpenSandbox server behind the `opensandbox`
adapter ([OpenSandbox pools](../guides/opensandbox-pools.md)).
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index d73f45c..b377d0d 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -3,7 +3,7 @@
| | |
|---|---|
| Status | As built, documented from the code on 2026-10-28, after the move to OpenSandbox removed Differently's own Firecracker node agent |
-| Scope | Control plane (`differently-server`), CLI, built-in adapters (16 provider adapters in the [provider catalog](../providers/README.md), `remote`, and the development-only `devhost` and `fake`), storage, HA, networking |
+| Scope | Control plane (`differently-server`), CLI, adapters (in the default build: `opensandbox`, `daytona`, `freestyle`, `remote`, and the development-only `devhost` and `fake`; 13 experimental provider adapters behind the `experimental_adapters` build tag, listed in the [provider catalog](../providers/README.md)), storage, HA, networking |
| Related | [Trust model](../security/trust-model.md) · [Adapter specification](../adapters/specification.md) · [Placement policy](../scheduling/placement-policy.md) · [Storage and lifecycle](../storage-and-lifecycle.md) · [OpenSandbox pools](../guides/opensandbox-pools.md) |
## 1. Summary
@@ -15,8 +15,9 @@ installation runs an [OpenSandbox](https://github.com/opensandbox-group/OpenSand
server with the runtime that fits (Docker or Kubernetes containers, or
Firecracker microVMs with Fast Sandbox) and registers it as a pool. When
policy explicitly allows it, work can also go to an external provider such as
-E2B, Daytona or a cloud provider's sandbox service, or to any backend behind a
-`remote` plugin ([provider catalog](../providers/README.md)).
+Daytona or Freestyle, or to any backend behind a `remote` plugin. Adapters
+for more providers are experimental and outside the default build
+([provider catalog](../providers/README.md)).
Applications never name a backend. They ask for resources, capabilities and
a data classification; the policy engine picks a pool.
@@ -46,7 +47,7 @@ proven in production".
| `opensandbox` adapter | Fake-server tests that cover the differences found between OpenSandbox's spec and its images (`internal/adapters/opensandbox/opensandbox_test.go`). Real Docker and Kubernetes responses recorded in `testdata/recordings` and replayed in every `go test`. `TestLiveOpenSandbox` (build tag `live`) passes against OpenSandbox release 1.2.0 on the Docker runtime (Colima, arm64) and the Kubernetes runtime (k3s on Azure) | The Fast Sandbox runtime (`runtime: fastsandbox`) is tested only with a fake server, so microVM isolation through Differently is unverified ([backlog E18](../backlog.md#e18-fast-sandbox-spike)) |
| End-to-end suite (`test/e2e`) | Postgres, `differently-server` and a real OpenSandbox 1.2.0 server: 14/14 on the Docker runtime (arm64 Colima) and on the Kubernetes runtime (single-node k3s on Azure, x86_64, with a snapshot registry, signed ports and egress policy), 2026-10-29 ([results](../../test/e2e/README.md)) | Fast Sandbox; multi-node clusters and node drains |
| Helm chart | `helm lint` and `helm template` pass for every CI value set; installed on single-node k3s on Azure next to OpenSandbox's charts, and the published `v0.1.0-alpha.1` chart installed from public artifacts alone ([single VM](../deploy/single-vm.md)) | Managed EKS, GKE and AKS; multi-node clusters |
-| Other provider adapters | `agent-sandbox` ran live against a kind cluster. The other 14 (Daytona, Freestyle, E2B, Vercel, the AWS, Azure and Google Cloud services and more) are written against each provider's published API and tested with fake HTTP servers ([provider catalog](../providers/README.md)) | No live-account verification ([POC 7](../proof-of-concepts.md#poc-7-external-provider-live-account-verification)) |
+| Other provider adapters | Daytona and Freestyle ran against live accounts and are in the default build. The other 13 are experimental, compiled in only with the `experimental_adapters` build tag: `agent-sandbox` ran live against a kind cluster; the other 12 (E2B, Vercel, the AWS, Azure and Google Cloud services and more) are written against each provider's published API and tested with fake HTTP servers ([provider catalog](../providers/README.md)) | No live-account verification of those 12 ([POC 7](../proof-of-concepts.md#poc-7-external-provider-live-account-verification)) |
| `remote` plugin protocol (`pkg/adapter/adapterhttp`) | Round-trip tests of client and handler against the fake adapter (`pkg/adapter/adapterhttp/adapterhttp_test.go`) | A conformance suite for third-party plugins ([backlog E0](../backlog.md#e0-correctness-and-security-fixes-from-the-code-review), E0.34) |
## 3. Goals and non-goals
diff --git a/docs/assets/differently-splash-dark.svg b/docs/assets/differently-splash-dark.svg
index e0d9a2c..c00598b 100644
--- a/docs/assets/differently-splash-dark.svg
+++ b/docs/assets/differently-splash-dark.svg
@@ -1,6 +1,6 @@
Differently: the control plane for your sandboxes
-Apps and agents call one Differently API. Differently handles tenancy, quotas, policy placement and audit, and places each sandbox on OpenSandbox or your own plugin inside your infrastructure, or on a managed provider such as E2B or Daytona only when policy allows.
+Apps and agents call one Differently API. Differently handles tenancy, quotas, policy placement and audit, and places each sandbox on OpenSandbox or your own plugin inside your infrastructure, or on a managed provider such as Daytona or Freestyle only when policy allows.
@@ -32,7 +32,7 @@
Managed providers
-E2B · Daytona · Vercel · cloud sandboxes
+Daytona · Freestyle · adapter plugins
only if allowed
fail-closed, by policy
diff --git a/docs/assets/differently-splash-light.svg b/docs/assets/differently-splash-light.svg
index 58a0f52..635bc6d 100644
--- a/docs/assets/differently-splash-light.svg
+++ b/docs/assets/differently-splash-light.svg
@@ -1,6 +1,6 @@
Differently: the control plane for your sandboxes
-Apps and agents call one Differently API. Differently handles tenancy, quotas, policy placement and audit, and places each sandbox on OpenSandbox or your own plugin inside your infrastructure, or on a managed provider such as E2B or Daytona only when policy allows.
+Apps and agents call one Differently API. Differently handles tenancy, quotas, policy placement and audit, and places each sandbox on OpenSandbox or your own plugin inside your infrastructure, or on a managed provider such as Daytona or Freestyle only when policy allows.
@@ -32,7 +32,7 @@
Managed providers
-E2B · Daytona · Vercel · cloud sandboxes
+Daytona · Freestyle · adapter plugins
only if allowed
fail-closed, by policy
diff --git a/docs/deploy/README.md b/docs/deploy/README.md
index 275784e..68c600d 100644
--- a/docs/deploy/README.md
+++ b/docs/deploy/README.md
@@ -34,9 +34,11 @@ Pools are where sandboxes run:
- **`opensandbox`:** an OpenSandbox server. `trust` must be declared:
`private` if you operate the server and its runtime, `external` if someone
else does.
-- **Managed providers** (`daytona`, `freestyle`, `e2b`, `vercel` and the
- others in the [provider catalog](../providers/README.md)): optional
- capacity someone else runs. Managed kinds are always `trust=external`, and
+- **Managed providers** (`daytona` and `freestyle`; adapters for others,
+ such as `e2b` and `vercel`, are experimental and need a server built with
+ `-tags experimental_adapters`, see the
+ [provider catalog](../providers/README.md#what-the-server-contains)):
+ optional capacity someone else runs. Managed kinds are always `trust=external`, and
sandboxes are placed on them only when policy allows it.
- **`remote`:** an out-of-process adapter plugin speaking the
[adapter protocol](../adapters/specification.md). Its trust is the
@@ -94,6 +96,7 @@ are OpenSandbox prerequisites for that runtime.
| [aws.md](aws.md) | EKS, with `.metal` or nested-virtualization node groups for Fast Sandbox (Terraform in `deploy/terraform/aws`) |
| [gcp.md](gcp.md) | GKE Standard, with a nested-virtualization node pool for Fast Sandbox |
| [azure.md](azure.md) | AKS, with nested-virtualization-capable sizes for Fast Sandbox (experimental) |
+| [azure-vms.md](azure-vms.md) | Two Azure VMs with k3s: Fast Sandbox microVMs, managed Postgres, ACR and Key Vault (verified for OpenWork) |
| [bare-metal.md](bare-metal.md) | k3s, RKE2, Talos, and an OpenSandbox server on a plain Linux host |
| [air-gapped.md](air-gapped.md) | Mirroring images and the chart; running with no external egress |
| [day-2.md](day-2.md) | Upgrade, rollback, migrations, backup and restore, certificates, replacing a backend, uninstall |
@@ -146,8 +149,8 @@ Postgres against a real OpenSandbox server with the Docker runtime and passed
|---|---|---|---|
| OpenSandbox 1.2.0 with the Docker runtime (Docker Compose on Colima, arm64) | Containers on one Docker host | **tested** (end-to-end suite 14/14, 2026-10-29) | Checks the API contract, not isolation: containers share the host kernel |
| Single-node k3s on an Azure VM, Differently's chart plus OpenSandbox's charts | OpenSandbox Kubernetes runtime (pods) | **tested** (end-to-end suite 14/14, 2026-10-29) | [single-vm.md](single-vm.md). Stop/start, snapshots, fork, port URLs and egress need the [Kubernetes runtime setup](../guides/opensandbox-pools.md#kubernetes-runtime-snapshot-registry-signed-ports-and-egress) |
-| Any conformant Kubernetes 1.29+, Differently's chart plus an OpenSandbox server | Whatever OpenSandbox runs | documented | [kubernetes.md](kubernetes.md); multi-node not yet run ([POC 12](../proof-of-concepts.md#poc-12-opensandbox-kubernetes-runtime)) |
-| OpenSandbox Fast Sandbox on KVM nodes | Firecracker microVMs | **ran live** on one node: single-node k3s on Azure Standard_D8s_v6 (nested KVM), 2026-10-29 | Recorded adapter scenario and a streamed desktop; the end-to-end suite has not run. Needs an XFS reflink state root with room ([pools guide](../guides/opensandbox-pools.md#measured-on-a-workbot-desktop)). [POC 11](../proof-of-concepts.md#poc-11-fast-sandbox-through-the-adapter), [backlog E18](../backlog.md#e18-fast-sandbox-spike) |
+| Any conformant Kubernetes 1.29+, Differently's chart plus an OpenSandbox server | Whatever OpenSandbox runs | documented | [kubernetes.md](kubernetes.md); a two-node k3s cluster ran on Azure ([azure-vms.md](azure-vms.md)); managed clusters and node drains not yet run ([POC 12](../proof-of-concepts.md#poc-12-opensandbox-kubernetes-runtime)) |
+| OpenSandbox Fast Sandbox on KVM nodes | Firecracker microVMs | **ran live** on Azure (nested KVM), 2026-10-29: single-node k3s on Standard_D8s_v6, then two-node k3s with a Standard_D16s_v6 KVM node ([azure-vms.md](azure-vms.md)) | Recorded adapter scenario, the end-to-end suite on the microVM pool, and Workbot's desktop through OpenWork's runner; node drains not run. Needs an XFS reflink state root with room ([pools guide](../guides/opensandbox-pools.md#measured-on-a-workbot-desktop)). [POC 11](../proof-of-concepts.md#poc-11-fast-sandbox-through-the-adapter), [backlog E18](../backlog.md#e18-fast-sandbox-spike) |
| AWS EKS, `.metal` node group (Fast Sandbox) | Bare metal | unverified | Matches Firecracker's tested platforms. The Terraform module is **unvalidated** |
| AWS EKS, nested-virtualization node group (Fast Sandbox) | `NestedVirtualization` CPU option (M7i/M8i/C7i/C8i/R7i/R8i…) | unverified | EKS managed node group support was reported fixed in 2026. Firecracker calls nested a development setup |
| AWS with Karpenter (Fast Sandbox) | `EC2NodeClass` nested virtualization (v1.13.0+) | unverified | Not covered by the Terraform module |
diff --git a/docs/deploy/air-gapped.md b/docs/deploy/air-gapped.md
index 9106fcf..2a3d51c 100644
--- a/docs/deploy/air-gapped.md
+++ b/docs/deploy/air-gapped.md
@@ -22,8 +22,8 @@ network, is for its documentation to answer; it has not been checked here.
| Image | Needed for |
|---|---|
-| `ghcr.io/different-ai/differently-server:0.1.0` | Control plane, migrations, preflight |
-| `ghcr.io/different-ai/differently-cli:0.1.0` | `helm test` only |
+| `ghcr.io/different-ai/differently-server:0.1.0-alpha.3` | Control plane, migrations, preflight |
+| `ghcr.io/different-ai/differently-cli:0.1.0-alpha.3` | `helm test` only |
| `docker.io/library/postgres:17` | Only with the evaluation-only `postgresql.embedded` |
| `docker.io/library/busybox:1.37` | Only for the `kubectl debug` checks in these guides |
| OpenSandbox's server, `execd` and other component images | Your OpenSandbox installation; see step 3 |
@@ -35,9 +35,9 @@ Copy by digest so the internal tags point at exactly what you reviewed. Use
```sh
REG=registry.internal.example.com
for img in differently-server differently-cli; do
- skopeo copy --all docker://ghcr.io/different-ai/$img:0.1.0 docker://$REG/different-ai/$img:0.1.0
+ skopeo copy --all docker://ghcr.io/different-ai/$img:0.1.0-alpha.3 docker://$REG/different-ai/$img:0.1.0-alpha.3
done
-skopeo inspect docker://$REG/different-ai/differently-server:0.1.0 | jq -r .Digest
+skopeo inspect docker://$REG/different-ai/differently-server:0.1.0-alpha.3 | jq -r .Digest
```
`global.imageRegistry` replaces only the registry part of each image, so keep
@@ -48,9 +48,9 @@ production.
## 2. Mirror the chart
```sh
-helm package deploy/helm/differently # differently-0.2.0.tgz
-helm push differently-0.2.0.tgz oci://$REG/charts # or copy the .tgz across the gap
-helm show chart oci://$REG/charts/differently --version 0.2.0
+helm package deploy/helm/differently # differently-0.3.0.tgz
+helm push differently-0.3.0.tgz oci://$REG/charts # or copy the .tgz across the gap
+helm show chart oci://$REG/charts/differently --version 0.3.0
```
## 3. Mirror and configure OpenSandbox
@@ -102,7 +102,7 @@ networkPolicy:
```
```sh
-helm install dly oci://$REG/charts/differently --version 0.2.0 -n differently -f values-airgap.yaml
+helm install dly oci://$REG/charts/differently --version 0.3.0 -n differently -f values-airgap.yaml
```
With providers disabled, the server NetworkPolicy has no TCP 443 egress rule;
diff --git a/docs/deploy/azure-vms.md b/docs/deploy/azure-vms.md
new file mode 100644
index 0000000..7f42d11
--- /dev/null
+++ b/docs/deploy/azure-vms.md
@@ -0,0 +1,236 @@
+# Azure VMs with Fast Sandbox microVMs
+
+A production-shaped install on two Azure VMs: k3s, OpenSandbox with both its
+Kubernetes runtime (containers) and Fast Sandbox (Firecracker microVMs),
+Differently in front, and managed services around it. It is the layout that
+serves OpenWork's Workbot computers ([OpenWork guide](../guides/openwork.md)),
+with Freestyle and Daytona as external pools.
+
+> **Verified 2026-10-29** in `eastus`: k3s v1.36.5, OpenSandbox 1.2.0,
+> Differently `pre-release` after `v0.1.0-alpha.2`. Workbot's desktop ran on
+> Fast Sandbox through Differently, driven by OpenWork's runner; the adapter's
+> live scenario and the end-to-end tests listed in [Verify](#verify) passed.
+> One node per role: not highly available.
+
+| Piece | Choice |
+|---|---|
+| Control node | `Standard_D8s_v6`: k3s server, Differently (2 replicas), OpenSandbox servers, container sandboxes |
+| KVM node | `Standard_D16s_v6` with a 256 GiB Premium SSD (XFS, reflink) for Fast Sandbox: microVMs, template builds, artifact store |
+| Database | Azure Database for PostgreSQL Flexible Server, private access |
+| Snapshot registry | Azure Container Registry (Standard), a repository-scoped token |
+| Secrets | Key Vault through the Secrets Store CSI driver, the control node's managed identity |
+| Ingress | Caddy on each node with Let's Encrypt: the API on one host name, sandbox port URLs on another |
+
+## 1. Network, VMs and quota
+
+- **Nested virtualization.** `Dsv6` sizes expose `/dev/kvm` to Ubuntu 24.04
+ with security type `Standard`; check with `ls -l /dev/kvm` on the VM.
+- **Quota.** A new subscription allows 10 regional vCPUs; this layout needs
+ 24. `az quota update --resource-type dedicated` raised `cores` and
+ `StandardDsv6Family` at once (register `Microsoft.Quota` first).
+- **Postgres region.** Some subscriptions may not create Flexible Server in
+ every region (`az postgres flexible-server list-skus -l ` says
+ "Provisioning is restricted"). Put the server in a nearby region's VNet and
+ peer it with the nodes' VNet; pass the private DNS zone by resource ID and
+ link it to both VNets.
+- **Two public IPs**, one per node, each with a DNS label. Sandbox content
+ must not share an origin with the API and dashboard, so the sandbox gateway
+ gets its own host name.
+- **NSG:** 443 and 80 (ACME) from anywhere, 22 from operators only. Port URLs
+ are opened by people's browsers and the API by OpenWork's runner, so 443
+ is public; every API call still needs a key.
+
+Format the KVM node's data disk as XFS with reflink and mount it at
+`/var/lib/fast-sandbox`; Fast Sandbox's node check refuses anything else.
+
+## 2. k3s and registry access
+
+Install k3s with `--disable traefik --disable servicelb` on the control node
+and join the KVM node as an agent. Label them (for example
+`differently.dev/role=control` and `sandbox-runtime=fast-sandbox`).
+
+Give both nodes ACR credentials in `/etc/rancher/k3s/registries.yaml`. The
+token's scope map needs wildcards: the image committer pushes each sandbox's
+snapshot to its own repository under `opensandbox-snapshots/`, so a scope on
+`opensandbox-snapshots` alone fails with `insufficient_scope`.
+
+```sh
+az acr scope-map create -r -n cluster \
+ --repository 'opensandbox-snapshots/*' content/read content/write content/delete metadata/read metadata/write \
+ --repository 'images/*' content/read metadata/read
+az acr token create -r -n cluster --scope-map cluster
+```
+
+## 3. OpenSandbox, Kubernetes runtime
+
+As in [single-vm.md](single-vm.md#2-install-opensandbox), with these values:
+
+- controller: `snapshot.registry: /opensandbox-snapshots`,
+ `snapshotPushSecret` and `resumePullSecret` naming a docker-registry Secret
+ with the ACR token (in the `opensandbox` namespace);
+- server: `gateway.host` is the sandbox host name, route mode `uri`, Secure
+ Access keys; `max_sandbox_timeout_seconds` at least the longest TTL clients
+ ask for (OpenWork keeps computers 14 days: `2592000` covers it);
+- the ingress gateway chart in route mode `uri` with the same keys.
+
+## 4. Fast Sandbox
+
+Fast Sandbox has its own lifecycle server and ingress gateway (separate
+releases, here `opensandbox-server-fsb` and `opensandbox-ingress-gateway-fsb`)
+and needs:
+
+- **An artifact store** for templates and checkpoints. Garage (v2.4.1) on the
+ KVM node's data disk, with zstd compression and block dedup, keeps a 14 GB
+ desktop checkpoint to about 1.3 GiB. Give its Service a fixed `clusterIP`:
+ the node agent's registry config names it by address.
+- **A guest kernel with nftables.** The stock kernel lacks nftables' `inet`
+ family, `reject` and the owner match. Build Firecracker CI's 6.18.36 config
+ with `NF_TABLES_INET`, `NF_TABLES_IPV6`, `NFT_REJECT`, `NFT_REJECT_INET`,
+ `NFT_LOG`, `NFT_SOCKET`, `NFT_FIB_*`, `NETFILTER_XT_MATCH_OWNER` and
+ `NETFILTER_XT_MATCH_MULTIPORT` set to `y` (about five minutes on the D16s
+ node), copy it over `vmlinux.bin` in a derived
+ `fsb-sandboxtemplate-builder:release-1.2.0` image, and set
+ `controller.sandboxtemplateBuilderImage` to it. Templates built afterwards
+ use it.
+- **A source registry the template builder can read anonymously.** The
+ builder reads no pull secrets, so copy template images from ACR to a
+ `registry:2` bound to the KVM node's private address (never exposed by the
+ NSG). Pull them into a separate containerd namespace first
+ (`k3s ctr -n stage images pull --user ...`): k3s discards layers it has
+ unpacked, so pushing from `k8s.io` fails with `content digest not found`.
+- **The idle sweeper** (`[automation.idle_pause]` with Redis) and the
+ gateway's activity tracking and wake-on-access.
+- **The template store on a volume**: mount a hostPath at the Fast Sandbox
+ server's `/root/.opensandbox`, or a restart forgets every template.
+- **A warm SandboxPool.** With `poolMin` below `poolMax`, a create that needs a
+ new fastlet fails with `no eligible Fastlet` instead of waiting. On a
+ D16s_v6, three fastlets of two 2 vCPU / 4 GiB sandboxes each fit; set the
+ Differently pool's `maxConcurrency` to match.
+- **One desktop-sized template per node.** Fast Sandbox 1.2.0 caps each
+ node's converted-template cache at 20 GiB; a second 14 GB template evicts
+ the first, and the evicted one fails with `RuntimeNotReady ... no committed
+ restore set` instead of being pulled again. Delete superseded templates.
+- **Pause time is disk throughput.** Every pause writes, reads back and
+ uploads the full 14 GB checkpoint through the state-root disk, which a
+ Premium SSD P15 serves at about 175 MB/s on burst credits and 125 MB/s
+ without them. Templates pulled by 1.2.0 are dense; leaving their zero runs
+ as holes cut the pause on the test host
+ ([pools guide](../guides/opensandbox-pools.md#shortening-pauses)).
+
+Create templates with an explicit `entrypoint` (the image's own is not run)
+and, for a desktop, a `readiness.probe` (`cmd://...`) so the snapshot is taken
+once it is up. OpenWork's runner README has the Workbot template's settings.
+
+## 5. Key Vault through the CSI driver
+
+On VMs (not AKS), install `csi-secrets-store-provider-azure` with Helm and let
+it authenticate as a user-assigned identity on the control node
+(`useVMManagedIdentity: "true"`, `userAssignedIdentityID`). Assign the
+identity only to that node. Three things to know:
+
+- If your account cannot create role assignments, create the vault with
+ access policies (`--enable-rbac-authorization false`); a vault cannot be
+ switched without that right.
+- The CSI driver syncs a Kubernetes Secret only while some pod mounts the
+ volume, and the chart's pre-install migration Job reads the database Secret
+ before any server pod exists. Run a small Deployment (a `pause` container)
+ that mounts the volume, so the Secret exists first and stays fresh.
+- Store values without a trailing newline (`printf '%s'`): `--file` keeps it,
+ and a token or key with a newline no longer matches.
+
+The chart reads the synced Secret for the database URL, bootstrap token,
+secrets key (pin its `objectVersion`) and OpenSandbox key; a second
+OpenSandbox server's key can be a `file:` credential on the mount. Provider
+keys (Freestyle, Daytona) are stored in Differently (`secret:`), so they
+rotate without a restart.
+
+Sandboxes must not reach the instance metadata service, or a container on
+the control node could read the vault through its identity. The
+NetworkPolicies below cover that.
+
+## 6. Differently
+
+Install the chart with `config.store.driver: postgres`, two replicas pinned
+to the control node, `secrets.existingSecret` and `postgresql.external`
+from the synced Secret, the CSI volume in `server.extraVolumes`, and pools:
+
+```yaml
+opensandbox: # container pool on the Kubernetes runtime
+ runtime: kubernetes
+ ports: signed
+ portScheme: https
+ lineEndings: kept
+config:
+ pools:
+ - name: opensandbox-microvm
+ adapter: opensandbox
+ trust: private
+ maxConcurrency: 4
+ credentials: {file: /mnt/kv/osb-fsb-api-key}
+ config:
+ apiURL: http://opensandbox-server-fsb.opensandbox-system.svc/v1
+ runtime: fastsandbox
+ isolation: microvm
+ templateId:
+ poolCPU: "2"
+ poolMemory: 4Gi # equal to the SandboxPool's sandboxResources
+ portScheme: https
+ lineEndings: kept
+ features: exec,exec.stream,files,lifecycle.pause-resume,snapshots.disk,fork,ports.expose
+ - {name: freestyle, adapter: freestyle, trust: external, maxConcurrency: 20, credentials: {secret: freestyle-key}, config: {egress: public}}
+ - {name: daytona, adapter: daytona, trust: external, maxConcurrency: 10, credentials: {secret: daytona-key}, config: {target: us, class: container, snapshot: daytona-medium}}
+ policy: {external: overflow, externalPools: [freestyle], strategy: private-first}
+networkPolicy:
+ server:
+ egress:
+ databaseCIDRs: []
+ extra: # Fast Sandbox execd through the public sandbox host
+ - to: [{ipBlock: {cidr: /32}}]
+ ports: [{port: 443, protocol: TCP}]
+```
+
+Fast Sandbox execd is reached past the server proxy, through the ingress
+gateway's public URL, so the server needs egress to the sandbox host.
+
+## 7. Ingress and sandbox network policy
+
+Caddy runs as a host-network Deployment on each node and proxies with
+`flush_interval -1` (streamed exec and WebSockets): the API host to
+`dly-differently-server:8080` on the control node; the sandbox host on the
+KVM node, `/fsb/*` (prefix stripped) to the Fast Sandbox gateway and
+everything else to the Kubernetes runtime's gateway. Set the Fast Sandbox
+server's `gateway.host` to `/fsb`.
+
+NetworkPolicies, enforced by k3s:
+
+- namespace `opensandbox` (container sandboxes): egress to kube-dns and to
+ `0.0.0.0/0` except `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`,
+ `169.254.0.0/16`, `100.64.0.0/10` and `168.63.129.16/32`;
+- namespace `opensandbox-dataplane` (fastlets carry microVM traffic): no
+ `169.254.0.0/16`, `168.63.129.16/32` or the VNets;
+- template build pods (label `sandbox.fast.io/sandboxtemplate`) also need
+ the API server (6443) and the source registry, or builds fail when they
+ report their result.
+
+## Verify
+
+Checked on this layout:
+
+| Check | Result |
+|---|---|
+| Container sandbox egress | metadata service, wire server, Postgres, Kubernetes API and in-cluster Redis refused; the internet reachable |
+| Container pool stop/start through ACR | stop 77 s on the first push of the base layers, start 3 s, disk kept |
+| Fast Sandbox create, template on the node | about 2 s through the public API |
+| Workbot desktop on Fast Sandbox | its nftables egress guard loads; desktop and browser stream through the gateway's WebSocket |
+| Pause / resume, 2 vCPU / 4 GiB desktop | 174–186 s / under 1–2 s, processes and `/dev/shm` kept |
+| Resume during a pause, same desktop | [calls it off](../scheduling/capacity.md#pausing): 1.0 s during the upload, 41 s during the memory dump, process and files kept |
+| Fork | snapshot about 180 s, the fork running about 85 s later with the parent's disk, memory and processes |
+| Adapter live scenario (`fastsandbox-1.2.0`) | passed in 9 min 27 s |
+| Freestyle through Differently, Workbot snapshot | create 4 s, pause 44 s, resume 3 s, process kept |
+| End-to-end suite on the microVM pool | 14 of 14 run passed: limits, exec, files, pause and resume (memory, processes and `/dev/shm` kept), snapshot and fork, memory snapshot, port URL, idle pause and wake by a port request (2.3 s), quotas, delete, drift, audit and metrics, capability errors. Not run: the e2e desktop image (Workbot's desktop streamed instead), unpullable images (templates), Differently's own idle sweep (Fast Sandbox idles natively), provider switching (it drains pools) |
+
+To run the suite against such an install, set `DIFFERENTLY_E2E_POOL` to the
+microVM pool and `DIFFERENTLY_E2E_TEMPLATE` / `DIFFERENTLY_E2E_PYTHON_TEMPLATE`
+to its template, and point `DIFFERENTLY_E2E_METRICS_URL` at the scheduler
+leader's pod, not the Service: with two replicas only the leader counts
+transitions.
diff --git a/docs/deploy/azure.md b/docs/deploy/azure.md
index 309858a..ba0cf36 100644
--- a/docs/deploy/azure.md
+++ b/docs/deploy/azure.md
@@ -1,7 +1,9 @@
# Azure (AKS)
> **Quickest path:** a single Azure VM with k3s, following
-> [One Linux VM on any cloud](single-vm.md). Use this page when you run a
+> [One Linux VM on any cloud](single-vm.md). For Fast Sandbox microVMs and
+> managed services on VMs, see the verified
+> [Azure VMs with Fast Sandbox](azure-vms.md). Use this page when you run a
> managed Kubernetes cluster.
Status: **unverified, experimental**. No step here has been run.
diff --git a/docs/deploy/day-2.md b/docs/deploy/day-2.md
index 0982ef9..e19c80a 100644
--- a/docs/deploy/day-2.md
+++ b/docs/deploy/day-2.md
@@ -164,7 +164,7 @@ spec:
securityContext: {runAsNonRoot: true, runAsUser: 65532, seccompProfile: {type: RuntimeDefault}}
containers:
- name: migrate
- image: ghcr.io/different-ai/differently-server:0.1.0
+ image: ghcr.io/different-ai/differently-server:0.1.0-alpha.3
args: ["migrate", "--config", "/etc/differently/config.yaml"]
env:
- name: DIFFERENTLY_DATABASE_URL
diff --git a/docs/deploy/kubernetes.md b/docs/deploy/kubernetes.md
index 59f4a45..94ed3e9 100644
--- a/docs/deploy/kubernetes.md
+++ b/docs/deploy/kubernetes.md
@@ -9,7 +9,7 @@ Cloud-specific notes are in [aws.md](aws.md), [gcp.md](gcp.md) and
[bare-metal.md](bare-metal.md).
Status: **documented**. These steps have not been run on a real cluster or
-against a live OpenSandbox server. The chart (version 0.2.0) passes `helm lint`
+against a live OpenSandbox server. The chart (version 0.3.0) passes `helm lint`
and `helm template` locally for the value sets in `deploy/helm/differently/ci/`.
The examples use namespace `differently`, release `dly` and the chart from
diff --git a/docs/deploy/single-vm.md b/docs/deploy/single-vm.md
index 8f969e2..ab20cb2 100644
--- a/docs/deploy/single-vm.md
+++ b/docs/deploy/single-vm.md
@@ -10,7 +10,7 @@ command that creates the VM differs.
> OpenSandbox 1.2.0 and Differently `v0.1.0-alpha.1`. A sandbox ran within
> about 2 s, and the end-to-end suite passed 12/12 against this setup; with
> a snapshot registry, the ingress gateway and egress policy added
-> (2026-10-29), it passed 14/14. The commands below install `v0.1.0-alpha.2`,
+> (2026-10-29), it passed 14/14. The commands below install `v0.1.0-alpha.3`,
> which has not itself been installed on this setup yet. The AWS, Google
> Cloud and bare-metal commands are the equivalent steps, not yet run there.
@@ -120,7 +120,7 @@ configToml: |
host = "0.0.0.0"
port = 80
api_key = "$OSB_KEY"
- max_sandbox_timeout_seconds = 86400 # the longest sandbox TTL OpenSandbox accepts
+ max_sandbox_timeout_seconds = 2592000 # the longest TTL OpenSandbox accepts: above any client's (OpenWork: 14 days)
[log]
level = "INFO"
[runtime]
@@ -156,7 +156,7 @@ Use the chart and images from the
[latest release](https://github.com/different-ai/differently/releases):
```bash
-VERSION=0.1.0-alpha.2
+VERSION=0.1.0-alpha.3
curl -fsSLO https://github.com/different-ai/differently/releases/download/v$VERSION/differently-$VERSION.tgz
kubectl create namespace differently
@@ -201,7 +201,7 @@ kubectl -n differently port-forward svc/differently-server 8080:8080 &
```
From your laptop, tunnel to it (`ssh -L 8080:localhost:8080 `) and use
-the CLI from the same release (`differently_v0.1.0-alpha.2__.tar.gz`):
+the CLI from the same release (`differently_v0.1.0-alpha.3__.tar.gz`):
```bash
export DIFFERENTLY_URL=http://localhost:8080 DIFFERENTLY_API_KEY="$(cat operator-token)"
diff --git a/docs/feature-parity.md b/docs/feature-parity.md
index baccfdb..ba6cc4f 100644
--- a/docs/feature-parity.md
+++ b/docs/feature-parity.md
@@ -111,7 +111,10 @@ Boat is the developer-experience reference. Boat paths are relative to
| Pause / resume (memory kept) | No | `POST …/pause`, `POST …/resume` | ✅ e2e | OpenSandbox's Docker runtime; OpenSandbox Fast Sandbox (`runtime: fastsandbox`, microVM checkpoint), run live: pause about 3 min for a 4 GiB desktop, resume 0.9 s; Freestyle run live (about 0.5 s, processes kept); Daytona's VM class 🧪. OpenSandbox's Kubernetes runtime keeps no memory, so it offers stop/start instead. |
| Disk snapshot | Automatic every minute | `POST …/snapshots {kind: disk}` | ✅ e2e | OpenSandbox on both runtimes (Kubernetes: an OCI image in the snapshot registry, ready in about 7 s). |
| Memory snapshot | No | `kind: memory` | 🟡 | Freestyle (always memory+disk; run live, and a fork from one keeps running processes), Daytona VM class (🧪). OpenSandbox: not on the Docker or Kubernetes runtime (root filesystem only); Fast Sandbox and its QEMU VMState mode are documented to capture memory, unverified. |
-| Fork | Disk copy | `POST …/fork` | ✅ e2e | Same pool as the source: snapshots are not portable. |
+| Fork | Disk copy | `POST …/fork` | ✅ e2e | Same pool as the source: snapshots are not portable. On Fast Sandbox the fork keeps the parent's disk, memory and processes (snapshot about 180 s for a 4 GiB desktop). |
+| A paused sandbox gives its pool slot back | No | Automatic on pools that checkpoint on pause (`lifecycle.pause-checkpoint`: Fast Sandbox, Freestyle); a resume on a full pool is queued (202) | ✅ | In-process tests and a capacity model ([capacity](scheduling/capacity.md)); not yet run on a full live pool. |
+| A pause in progress shows and refuses work | No | Status `pausing`: exec, file and port calls answer `invalid_state`. `POST …/resume` calls the pause off on Fast Sandbox and otherwise waits for it | ✅ live | Run on a real Fast Sandbox: resume during the upload 1.0 s, during the dump 41 s, processes and files kept ([capacity](scheduling/capacity.md#pausing)). The called-off checkpoint is not cleaned up. |
+| Lifecycle limits | No | `maxRunSeconds` (pause or stop after running that long), `maxPausedSeconds` (delete after staying paused), per request or as operator defaults | ✅ | |
| Saved / named snapshots, restore by name | Yes | `fromSnapshot` on create; `GET /v1/snapshots` | ✅ | |
| Point-in-time restore | No | No | — | |
| Volumes / persistent disks independent of a sandbox | No | — | 📋 | |
@@ -122,12 +125,13 @@ Boat is the developer-experience reference. Boat paths are relative to
| Capability | Boat | Differently | Status |
|---|---|---|---|
| Scoped API keys | Yes | `POST /v1/api-keys`; scopes, project-bound, expiry; no-escalation rule; rotation with a grace period of up to 7 days (`POST /v1/api-keys/{id}/rotate`) | ✅ |
-| Provider credentials managed in the product | n/a | `PUT /v1/admin/secrets/{name}`, used by pools as `secret:name`; replacing a value takes effect without a restart. Not yet tried against live Daytona or Freestyle accounts ([guide](guides/keys-and-providers.md)) | ✅ |
+| Provider credentials managed in the product | n/a | `PUT /v1/admin/secrets/{name}`, used by pools as `secret:name`; replacing a value takes effect without a restart. In use for Freestyle and Daytona on the [Azure install](deploy/azure-vms.md); live rotation not run yet ([guide](guides/keys-and-providers.md)) | ✅ |
| Organizations / projects | Orgs, seats | Operator-managed orgs, tenant-managed projects | ✅ |
| Quotas (active, queued, CPU, memory, disk, TTL, snapshots) | Plan limits | Per org and project, enforced atomically | ✅ e2e |
| Fair queueing across tenants | n/a | Round-robin by least active CPU | ✅ |
| Suspend a tenant | n/a | `POST /v1/admin/organizations/{id}/suspend` (optionally terminate) | ✅ |
| Drain a pool | n/a | `POST /v1/admin/pools/{name}/drain` | ✅ |
+| Preemption on a full pool | n/a | `preemption.mode: idle` or `lru` pauses running sandboxes to make room for waiting work; `preemptible: false` opts a sandbox out | ✅ |
| Switch which provider new sandboxes go to | n/a | `POST /v1/admin/pools/{name}/use` drains every other pool; running sandboxes stay; external pools need approval; every replica picks it up within about 5 s | ✅ |
| Webhooks | 6 lifecycle events, HMAC | — | 📋 |
| OpenTelemetry | Metrics + logs | Prometheus `/metrics`, structured JSON logs | 🟡 OTel traces 📋 |
@@ -140,9 +144,13 @@ Boat is the developer-experience reference. Boat paths are relative to
## 3. Backend capability matrix
-What each Differently adapter advertises today (from the adapters' code). A
-request that needs a capability is only placed on pools that advertise it; an
-operation a backend lacks returns `unsupported_operation` (501).
+What each adapter in the default build advertises today (from the adapters'
+code). A request that needs a capability is only placed on pools that
+advertise it; an operation a backend lacks returns `unsupported_operation`
+(501). The 13 experimental adapters, compiled in only with the
+`experimental_adapters` build tag and, apart from `agent-sandbox`, tested
+only against fake servers, are in the
+[provider catalog](providers/README.md#what-the-server-contains).
| Capability | `opensandbox` (default private runtime) | `daytona` | `freestyle` | `remote` (plugin) | `devhost` (dev only) |
|---|---|---|---|---|---|
@@ -152,8 +160,8 @@ operation a backend lacks returns `unsupported_operation` (501).
| `exec.stream` | ✅ e2e | — (falls back to buffered) | — (falls back to buffered) | As advertised | ✅ |
| `files` | ✅ e2e | ✅ live | ✅ live | As advertised | ✅ |
| `lifecycle.stop-start` / `persistence.disk` | ✅ e2e with `runtime: kubernetes` and a snapshot registry (root filesystem kept); not on Docker | ✅ live | ✅ live (stop = in-guest poweroff) | As advertised | ✅ |
-| `lifecycle.pause-resume` | ✅ e2e (Docker runtime; memory kept); not on Kubernetes, which keeps no memory | 🧪 VM class only | ✅ live | As advertised | — |
-| `snapshots.disk` / `fork` | ✅ e2e (Docker; Kubernetes with a snapshot registry) | ✅ live (fork from a disk snapshot) | ✅ live | As advertised | ✅ |
+| `lifecycle.pause-resume` | ✅ e2e (Docker and Fast Sandbox runtimes; memory kept); not on Kubernetes, which keeps no memory | 🧪 VM class only | ✅ live | As advertised | — |
+| `snapshots.disk` / `fork` | ✅ e2e (Docker; Kubernetes with a snapshot registry; Fast Sandbox) | ✅ live (fork from a disk snapshot) | ✅ live | As advertised | ✅ |
| `snapshots.memory` | 🧪 opt-in per pool (Fast Sandbox) | 🧪 VM class only | ✅ live; always memory+disk | As advertised | — |
| `ports.expose` | ✅ e2e with `ports: signed` (ingress gateway, signed time-limited URLs, no header); off otherwise | ✅ live; signed preview URL | ✅ live; public TLS host | As advertised | — |
| `network.egress-control` | ✅ live with `egress: none` or `allowlist` (Kubernetes runtime) | — | ✅ live; firewall (deny by default; `egress: public` to allow) | As advertised | — |
@@ -197,7 +205,7 @@ Against what Freestyle gives Workbot computers, as of 2026-10-29:
| Per-request port URL lifetime | **Not built yet** | URLs last the pool's `portURLSeconds`; the ports API takes no `ttlSeconds`. |
| Egress on the Docker runtime | **Not checked** | The setting is sent on both runtimes; enforcement was checked on Kubernetes only. |
| Detached processes, PTY, SSH | **Not built yet** | OpenSandbox's execd offers background commands; not wired into the adapter. |
-| Fast Sandbox: pause speed, guest firewall, guest clock | **Limitation** of OpenSandbox 1.2.0 | Each pause uploads a full checkpoint (minutes for a 4 GiB desktop); the stock guest kernel cannot load per-user firewall rules; the guest clock is not stepped after a restore. Details in the [pools guide](guides/opensandbox-pools.md#measured-on-a-workbot-desktop). |
+| Fast Sandbox: pause speed, guest firewall, guest clock | **Limitation** of OpenSandbox 1.2.0; the clock is handled by Differently | Each pause uploads a full checkpoint (minutes for a 4 GiB desktop; sparse templates cut it by about a fifth, [research](research/fast-sandbox-pause-time.md)), so pools wait up to `checkpointTimeoutSeconds`; the stock guest kernel cannot load per-user firewall rules (a guest kernel with nftables can); OpenSandbox does not step the guest clock after a restore, so the adapter sets it after create and resume. Details in the [pools guide](guides/opensandbox-pools.md#measured-on-a-workbot-desktop). |
## 5. Boat-compatible facade: decision
@@ -230,10 +238,10 @@ portable core only, and document every semantic difference. Mapping sketch:
## 6. Biggest gaps, in priority order
-1. Run the end-to-end suite on OpenSandbox's Fast Sandbox runtime and on a
- multi-node Kubernetes cluster under Differently (Fast Sandbox has run
- live on one node). Run Daytona's VM class (pause, memory snapshots) on an
- account that offers it.
+1. Node drains and more than one KVM node under Differently (the
+ end-to-end suite has passed on a Fast Sandbox pool on a two-node
+ cluster). Run Daytona's VM class (pause, memory snapshots) on an account
+ that offers it.
2. Use pool egress policy in placement, then per-request egress allow-lists.
3. An SSH gateway.
4. Detached processes and PTY sessions.
diff --git a/docs/guides/keys-and-providers.md b/docs/guides/keys-and-providers.md
index f534e99..a08c699 100644
--- a/docs/guides/keys-and-providers.md
+++ b/docs/guides/keys-and-providers.md
@@ -11,9 +11,10 @@ covers:
stored credentials.
> **Evidence level.** Unit and in-process API tests cover everything here
-> ([What is verified](#what-is-verified)). None of it has run against live
-> Daytona or Freestyle accounts, on a real cluster, or with more than one
-> server replica. Try it in a staging installation first.
+> ([What is verified](#what-is-verified)). On a two-replica install on Azure
+> the Freestyle and Daytona keys are stored this way (`secret:`) and Freestyle
+> sandboxes ran with them ([azure-vms.md](../deploy/azure-vms.md)); rotation
+> and switching have not run there. Try them in a staging installation first.
The examples use the CLI with an operator key and the Helm release `dly`
in namespace `differently`:
@@ -611,12 +612,14 @@ Tests on this branch:
Not verified:
-- live Daytona and Freestyle accounts (their adapters are tested only
- against fakes);
+- rotating a stored key, or switching providers, against live accounts
+ (stored keys are in use on the Azure install, where Freestyle sandboxes
+ ran with them);
- `file:` pickup from a real Kubernetes Secret volume or Key Vault CSI mount.
It uses the same code path as stored secrets but has no test of its own;
-- more than one server replica on a real cluster (covered only by a unit
- test with two pool managers sharing a store; see
+- a rotation or a switch seen by more than one server replica on a real
+ cluster (two replicas run on the Azure install, but neither was exercised
+ there; a unit test covers two pool managers sharing a store, see
[several replicas](#several-server-replicas));
- the chart's generated secrets key and `runtimeProviders` egress on a real
cluster.
diff --git a/docs/guides/opensandbox-pools.md b/docs/guides/opensandbox-pools.md
index b611ffe..0550b17 100644
--- a/docs/guides/opensandbox-pools.md
+++ b/docs/guides/opensandbox-pools.md
@@ -269,14 +269,54 @@ Differently on the host above:
|---|---|
| Template build, once per image | about 10 min, mostly writing the 4 GiB memory snapshot (178 s) |
| Create, template already on the node | 1.2 s to running |
-| Pause, memory kept | 179 s: the whole checkpoint (root filesystem and memory) is written and uploaded to the artifact store |
+| Pause, memory kept | 190–231 s (median 207 s idle, 216–222 s in use) with templates as Fast Sandbox 1.2.0 caches them; a median 162 s idle and 182 s in use (114 s for the first pause after a create) with the template's zero runs left as holes ([below](#shortening-pauses)) |
| Resume | 0.9 s, with the same processes and unsaved work on screen |
| Idle pause (`idleTimeoutSeconds: 60`) | 60 s idle, then the same checkpoint |
| Wake by a request to a port URL | 0.67 s for the first response, 0.27 s once awake |
Slow pause is a limitation of Fast Sandbox on this storage: every pause
-writes and uploads a full checkpoint, so it grows with memory size and disk
-speed (a P6 managed disk here). Resume and wake are fast.
+writes, reads back, hashes and uploads a full checkpoint (13.96 GB for this
+template, whatever changed), so it grows with the template's size and the
+state root's throughput. Resume and wake are fast.
+
+#### Shortening pauses
+
+Measured on the test host with Workbot's desktop; method, phases and raw
+numbers in [research/fast-sandbox-pause-time.md](../research/fast-sandbox-pause-time.md).
+
+- **Leave templates sparse.** Fast Sandbox 1.2.0 writes a pulled template
+ densely: the 9 GiB root filesystem and 4 GiB memory file take 13 GiB on
+ disk though 3.5 GiB is data. Every pause then reads the zeros back from
+ disk. With the zero runs as holes the median pause fell 17–22% (45% for
+ the first pause after a create), resumes stayed as fast and the desktop
+ kept its state. The fix belongs in Fast Sandbox's pull
+ ([patch](../research/fast-sandbox-sparse-pull.patch)). Until it ships, an
+ operator can punch the holes into a newly pulled template before putting
+ it in service: the first create pulls it, so create and delete one
+ sandbox, then run `test/pause/punch.py` on the cached `rootfs.img` and
+ `memory.snap` under `/var/lib/fast-sandbox/firecracker/images/` with no
+ sandbox on the node. Content and digests do not change (compare them with
+ the entry's `manifest.json`); a later pull of the same template writes it
+ densely again.
+- **Mind the disk's throughput, not its size.** Every phase is limited by
+ the state root: about 175 MB/s through a P6 or P15 on burst credits,
+ reads and writes together. When the credits run out the disk falls to its
+ baseline (50 MB/s on a P6, 125 MB/s on a P15) and an in-use pause took
+ 316–340 s here. Provision throughput (Premium SSD v2, or a local NVMe disk)
+ for nodes that pause often.
+- **A RAM spill area shortens the freeze, not the pause.**
+ `FAST_SANDBOX_SNAPSHOT_SPILL_DIR` on a memory-backed volume took the
+ memory dump from 26–49 s to 1.6 s, but the same bytes still reach the disk
+ afterwards and the pause took as long (210 s). It helps snapshots and
+ forks of running sandboxes, whose guest is frozen for the dump. Firecracker
+ runs in the fastlet's memory cgroup, so a RAM spill needs the fastlet's
+ `fastletOverhead` in `fast-sandbox-runtime-environments` raised by one
+ dump (dumps run one at a time per fastlet), or two busy VMs plus a dump can
+ exceed its limit.
+- **Keep the node cache under 20 GiB.** A fastlet restart evicts cache
+ entries over the cap, least used first, and that can be the desktop
+ template while stale checkpoints stay; the next create then pulls 14 GB
+ again.
What a template and host need:
@@ -302,7 +342,7 @@ Gaps found running Workbot's desktop:
| The template builder pulls the source image anonymously (no pull secrets) | **Limitation**; copy private images to a registry the builder can reach without credentials, such as one on the KVM node's private address |
| Each KVM node caches converted templates up to a fixed 20 GiB, and evicted templates are not re-pulled (`RuntimeNotReady ... no committed restore set`) | **Limitation** of 1.2.0; keep one desktop-sized template (about 14 GB) per node and delete superseded templates |
| Port URLs (route scopes) never expire | **Limitation**; keep the gateway private or treat URLs as credentials |
-| Pause takes minutes for a 4 GiB desktop | **Limitation** (full checkpoint per pause) |
+| Pause takes minutes for a 4 GiB desktop | **Limitation** of 1.2.0 (full checkpoint, read twice, per pause); sparse templates cut it (above). Upstream changes that would cut it further are listed in the [research](../research/fast-sandbox-pause-time.md#remaining-ideas-that-need-an-upstream-change) |
| One template per pool; Differently shows the requested size, not the template's | **Not built yet** |
## Isolation, egress and credentials
diff --git a/docs/guides/openwork.md b/docs/guides/openwork.md
index 8ab87af..8aa022e 100644
--- a/docs/guides/openwork.md
+++ b/docs/guides/openwork.md
@@ -7,11 +7,13 @@ contract against this API. OpenWork then holds a single Differently key.
Differently holds the OpenSandbox, Daytona and Freestyle credentials, and
decides by policy where each computer runs.
-Status: **documented and unit-tested on both sides; not run end to end**.
-Differently's side is covered by its tests and the e2e suite. The OpenWork
-adapter passes OpenWork's conformance cases against a fake Differently
-server. Nobody has yet driven a Workbot runner through a real Differently
-installation.
+Status: **run end to end on 2026-10-29.** OpenWork's headless runner, run
+locally, drove Workbot computers through an install on Azure VMs
+([azure-vms.md](../deploy/azure-vms.md)): a model turn used the computer on a
+Fast Sandbox microVM, Workbot opened a page in the desktop's browser, the
+viewer streamed it, and OpenWork's `proof:live` passed through the gateway,
+pause and resume included. The runner fell back to its own provider when the
+gateway could not start a computer.
## What OpenWork needs, and where Differently provides it
@@ -99,15 +101,20 @@ config:
templates:
- name: workbot-computer
description: Workbot's Linux computer
- images:
- opensandbox: ghcr.io/example/workbot-computer:2026-10-28 # OpenSandbox image (or a Fast Sandbox template ID)
- daytona: workbot-computer-v12 # Daytona snapshot name
- freestyle: workbot-computer-v12 # Freestyle snapshot ID or slug
+ pools: [opensandbox-microvm, freestyle]
+ images: # keyed by pool name
+ opensandbox-microvm: tpl-... # a Fast Sandbox template ID (the pool's templateId)
+ freestyle: workbot-desktop-freestyle-0123456789abcdef # the Freestyle snapshot's slug
+ # opensandbox: /workbot-computer: # an image, on the container runtimes
+ # daytona:
```
A template with `images` runs only on the pools it lists, and placement
explanations say so. A template name with no configured entry is passed to
-the backend unchanged, as before.
+the backend unchanged, as before. Freestyle's VM create takes the snapshot's
+slug (the name OpenWork's snapshot builder prints), not its `sh-...` id.
+Workbot's desktop needs Daytona's `linux-vm` class, so leave Daytona out of
+the template where the account has none.
## 2. Give OpenWork a project and a key
@@ -180,8 +187,20 @@ should use it; every other organization keeps the runner's own provider.
their own deletion deadline ([backlog E0.20](../backlog.md)). Keep their
TTLs generous or unset there.
- **Desktop viewers** need a pool whose backend exposes ports without the
- backend's API key: Freestyle (public TLS host) or Daytona (signed preview
- URL). OpenSandbox's proxied endpoints are not handed out.
+ backend's API key: Freestyle (public TLS host), Daytona (signed preview
+ URL), or OpenSandbox behind its ingress gateway (signed URLs on the
+ Kubernetes runtime, route scopes on Fast Sandbox). OpenSandbox's proxied
+ endpoints are not handed out.
+- **OpenSandbox's maximum timeout.** The adapter sends a computer's TTL as
+ OpenSandbox's own expiry, and the runner keeps computers
+ `HEADLESS_COMPUTER_KEEP_DAYS` (14 by default). Set the OpenSandbox servers'
+ `max_sandbox_timeout_seconds` above that, or every create is refused and
+ the runner falls back to its own provider.
+- **Fast Sandbox pause takes minutes** (a full checkpoint: about three for a
+ 4 GiB desktop, more once a burstable disk runs out of credits; see
+ [shortening pauses](opensandbox-pools.md#shortening-pauses)). Differently
+ finishes it even when the runner stops waiting; OpenWork's runner gives
+ pauses five minutes.
- **Den's Cloud workers** need detached processes and persistent volumes;
both are in the [backlog](../backlog.md) and Den stays on Daytona until
then.
diff --git a/docs/maintaining.md b/docs/maintaining.md
index 1a19a05..1f5d2fc 100644
--- a/docs/maintaining.md
+++ b/docs/maintaining.md
@@ -8,8 +8,9 @@ page holds the reasoning and the commands.
Differently is a control plane. It owns tenancy, keys, quotas, fair queueing,
placement policy, reconciliation, audit and the trust report. It runs no
-sandboxes itself: OpenSandbox (the default private runtime), Daytona,
-Freestyle and `remote` plugins do, behind the adapter contract. Most bugs worth
+sandboxes itself: OpenSandbox (the default private runtime), the other
+[provider adapters](providers/README.md) and `remote` plugins do, behind the
+adapter contract. Most bugs worth
fixing sit where the two meet: what a backend really returns, and what the
control plane does with it.
@@ -19,19 +20,30 @@ control plane does with it.
|---|---|---|
| Public API (a route, a field, an error) | `pkg/api`, `internal/server`, `api/openapi.yaml`, the SDKs | `internal/server` tests (the OpenAPI test keeps the router and spec in sync); SDK tests |
| Placement, policy, classification | `internal/policy`, `internal/scheduler` | `internal/server/server_test.go` (acceptance scenarios A–L) |
+| Slots, the resume queue, preemption, lifecycle limits | `internal/scheduler/capacity.go`, `internal/store` (`HoldsPoolSlot`, `ReclaimSlot`), the model in `internal/capsim` | The capacity tests named in [capacity.md](scheduling/capacity.md); store conformance through both stores; rerun `go run ./cmd/differently-capsim` when a strategy changes |
| Quotas, admission, activation | `internal/store` (memory and Postgres) | `internal/store/storetest` through both stores; `make test-postgres` |
| Reconciliation and recovery | `internal/scheduler` (`Reconcile`, `retry`) | Server tests using the fake backend; the e2e drift test |
| A backend's behaviour | `internal/adapters/` | Its fake-server tests; for OpenSandbox also the recordings ([below](#recordings-real-backends-replayed-in-go-test)) |
+| A new provider kind | The adapter, then every registration list in [the provider catalog's checklist](providers/README.md#add-a-built-in-adapter) | `TestEveryAdapterKindHasATrustRule`, `TestRegisteredKindsMatchConfig`; its fake-server tests, which run only with `-tags experimental_adapters` (`make experimental`) |
| The adapter contract | `pkg/adapter`, `pkg/adapter/adapterhttp`, `internal/adapters/fake` | `adapterhttp` round-trip tests; [adapter specification](adapters/specification.md) |
| Stored credentials and keys | `internal/secrets`, `internal/server` | [keys-and-providers.md](guides/keys-and-providers.md) lists what is verified |
| Install and operations | `deploy/helm/differently` (values, schema, templates, NOTES), `cmd/differently-server` (`preflight`, `migrate`) | `make helm-lint`; the ci value sets; the deploy guides |
| The OpenWork integration | This repository's API and [docs/guides/openwork.md](guides/openwork.md); the client lives in OpenWork (`packages/sandbox-differently`) | OpenWork's provider tests against a running Differently |
+| The built-in dashboard | `internal/dashboard/static` | Load it against `differently-server --dev` |
+| The admin console | `console/` ([README](../console/README.md)); `src/lib/providers.ts` follows the dashboard's `kinds` | `npm run typecheck`, `npm run build` and `npm run build:server` (CI's `console` job); its pages against dev mode |
+| The website | `site/src/pages/index.astro` (landing page), `site/pages-src/features.md`, the sidebar in `site/astro.config.mjs`; every other page syncs from `docs/**` and the READMEs | `npm run build` in `site/` validates every link |
Docs that must stay truthful with the code: [feature-parity.md](feature-parity.md),
[roadmap.md](roadmap.md), [acceptance-scenarios.md](acceptance-scenarios.md),
the [trust model](security/trust-model.md) and the `limitations()` the trust
report returns.
+A verified result is stated in several places: the page that recorded it
+(a deploy guide, [test/e2e](../test/e2e/README.md), a provider page), then
+the README's status list, the roadmap, feature parity, the website's landing
+page and its Features page. Update them in the same PR, so no page still
+says "not yet" about something another one reports as run.
+
## Prove a change at the right cost
Climb only as far as the claim needs. Each rung is slower and more real than
@@ -40,12 +52,12 @@ the one before; repeating a rung for reassurance adds nothing.
| Rung | Command | Use it for |
|---|---|---|
| Package tests | `go test ./internal/adapters/opensandbox/` (or the package you changed) | Every change, while iterating |
-| CI locally | `make verify` | Before every push: gofmt, vet, tests with `-race`, SDK tests, Helm lint |
+| CI locally | `make verify` | Before every push: gofmt, vet, tests with `-race`, the same again with the experimental adapters (`make experimental`: build, vet and test with `-tags experimental_adapters`), SDK tests, Helm lint |
| Postgres store | `make test-postgres` (needs Docker) | Changes in `internal/store` |
| Recorded runtimes | part of `go test` (`TestReplayRecordings`) | Any OpenSandbox adapter change: real responses from the Docker and Kubernetes runtimes, no server |
| Live adapter | `OPENSANDBOX_URL=… go test -tags live -run TestLiveOpenSandbox ./internal/adapters/opensandbox/`; for the managed providers `DAYTONA_API_KEY=… go test -tags live -run TestLiveDaytona ./internal/adapters/daytona/` and `FREESTYLE_API_KEY=… go test -tags live -run TestLiveFreestyle ./internal/adapters/freestyle/` (a few small sandboxes, all deleted) | A new OpenSandbox call or version; re-recording; any Daytona or Freestyle adapter change |
| Real stack, local | `make e2e-prepare e2e-up e2e` | Behaviour across the API, scheduler, store and a real OpenSandbox server |
-| Real stack, a real cluster | the e2e suite pointed at an install ([test/e2e](../test/e2e/README.md#run-against-another-deployment)) | Helm, NetworkPolicy, the Kubernetes runtime, upgrades |
+| Real stack, a real cluster | the e2e suite pointed at an install ([test/e2e](../test/e2e/README.md#run-against-another-deployment)) | Helm, NetworkPolicy, the Kubernetes and Fast Sandbox runtimes, upgrades |
CI runs the first four on every PR. The live, e2e and cluster rungs need a
server and are run by hand; record what ran, where and with which versions in
@@ -56,6 +68,11 @@ PR what did not run.
On macOS, run Docker stacks from a checkout under your home folder: Colima
shares only `$HOME` with its VM, so bind mounts from `/tmp` fail.
+On a cluster, a Fast Sandbox pause or snapshot of a desktop takes minutes,
+so the suite there takes much longer than the Docker stack. Point
+`DIFFERENTLY_E2E_METRICS_URL` at the scheduler leader's pod rather than the
+Service: with several replicas only the leader counts transitions.
+
## Recordings: real backends, replayed in go test
`internal/adapters/opensandbox/testdata/recordings/*.json` are real
@@ -166,6 +183,16 @@ Self-hosting checklist for any change an operator will feel:
| Pause hangs, snapshots fail `RegistryNotConfigured` | The Kubernetes runtime needs `controller.snapshot.registry` | Configure the registry, or leave both out of the pool's `features` |
| stderr arrives before earlier stdout | `execd` reads the two pipes separately | Compare each stream on its own |
| `docker pull ghcr.io/different-ai/...` says `denied` on one machine only | A stale `ghcr.io` login in `~/.docker/config.json` | `docker logout ghcr.io`; release images are public, tagged without a leading `v` |
+| Times inside a Fast Sandbox microVM are hours or days behind | The guest clock starts at the time its template or checkpoint was written | The adapter sets it after create and resume; keep that step when changing the adapter |
+| A Fast Sandbox resume sometimes takes about two minutes | Its image cache GC deleted the checkpoint: a fastlet log shows `sandbox parked for async image delivery`, and earlier `image cache GC removed` with that cache key | Run the fastlet with [fast-sandbox-shared-image-cache.patch](research/fast-sandbox-shared-image-cache.patch), and carry the patch through Fast Sandbox upgrades until upstream has it |
+| A fastlet image change never rolls out on one KVM node | The planned upgrade adds a surge pod first, which does not fit, so it stays `Pending` while the old fastlets keep serving | With every sandbox paused, delete the old fastlet pods one at a time. Never delete one that has a VM: the VM dies with its fastlet |
+| A Fast Sandbox pause or snapshot times out | A 4 GiB desktop's checkpoint takes about three minutes, longer than the general ready timeout, and five or more once a burstable state-root disk runs out of credits | The pool's `checkpointTimeoutSeconds` (default 900); [shortening pauses](guides/opensandbox-pools.md#shortening-pauses) |
+| Fast Sandbox template builds fail when they report their result | The build pods cannot reach the Kubernetes API (6443) or the source registry | Allow both in the sandbox network policy ([azure-vms.md](deploy/azure-vms.md)) |
+| A client shows a date of 31 December, year 1 | The API sends Go's zero time (`0001-01-01T00:00:00Z`) for a time never set | Treat it as unset, as `isSet` in `console/src/lib/ui.ts` does |
+| In `console/`, `npm run dev` answers 404 for `/v1/...` | Astro's `trailingSlash: 'always'` rejects paths without a slash before any Vite middleware runs | Leave `trailingSlash` unset in `console/astro.config.mjs` |
+| A PR shows no CI run at all | GitHub runs no `pull_request` workflows while a PR has merge conflicts | Merge `pre-release` in, resolve, push |
+| CodeQL reports `go/allocation-size-overflow` | `make([]byte, n+len(msg))` with an unbounded length | Check the size against a limit before allocating |
+| `gh pr merge --auto` on a `pre-release` PR fails with "unstable status" | `pre-release` has no required checks, so there is nothing for auto-merge to wait for | Check the CI run, then merge |
## Releasing
@@ -175,3 +202,27 @@ approval `main` needs once every job is green; a release is a squash-merged
`pre-release` → `main` PR followed by a `vX.Y.Z` tag on `main`, which builds
the binaries, the Helm chart and the images (`ghcr.io/different-ai/differently-{server,cli}:X.Y.Z`);
`sync-pre-release` merges `main` back.
+
+What the releases so far have shown:
+
+- Alpha and RC tags are signed annotated tags on `pre-release`
+ (`git tag -s vX.Y.Z-alpha.N -m vX.Y.Z-alpha.N `); the release
+ workflow takes every version from the tag. `checksums.txt` covers the
+ binary archives, not the chart.
+- A release prep PR changes the CHANGELOG, the chart's `appVersion`
+ (`deploy/helm/differently/Chart.yaml`, the image tag a checkout install
+ pulls) and the version strings in the docs and on the website
+ (`site/src/pages/index.astro`). Merge it and push
+ the tag one after the other, so the website never names a release that
+ is not published yet.
+- `main` follows `pre-release` through a PR titled
+ `chore: merge pre-release into main`. Its auto-merge can stay `BLOCKED`
+ with every check green and the CI approval present. Merge it with the
+ GraphQL `mergePullRequest` mutation, pinned to the head CI checked
+ (`expectedHeadOid`), then confirm in
+ `GET /repos/different-ai/differently/rulesets/rule-suites?ref=refs/heads/main`
+ that every rule passed. A rule that did not pass means the merge used a
+ bypass: say so in the PR and fix the cause.
+- A ruleset stops `pre-release` from being deleted when a release PR merges
+ (the repository deletes merged head branches). If it is ever gone,
+ recreate it at the release PR's head and rerun `sync-pre-release`.
diff --git a/docs/providers/README.md b/docs/providers/README.md
index fcd3296..b5bfceb 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -20,20 +20,52 @@ code and published documentation.
## Providers
-26 providers are covered: 16 with a built-in adapter, 8 reachable through a
-[remote plugin](remote.md), and 2 that cannot be supported. Four of the 16
-have run live; the rest are implemented from published APIs and tested
-against fakes of them. "Persists" means stop/start keeps the disk or pause
-keeps memory; "Snapshots" are disk unless marked memory.
+26 providers are covered: 16 with an adapter in this repository, 8 reachable
+through a [remote plugin](remote.md), and 2 that cannot be supported.
+
+### What the server contains
+
+The published image, the release binaries and `make build` contain three
+provider adapters, each verified against a real backend: `opensandbox`,
+`daytona` and `freestyle`. They also contain [`remote`](remote.md), the
+plugin protocol, and the dev-mode `devhost`.
+
+The other 13 adapters are **experimental**. Their code and tests are in the
+repository and CI builds and tests them, but they are compiled into the
+server only with the Go build tag `experimental_adapters`. Twelve have been
+tested only against fake servers; `agent-sandbox` also ran live once, on a
+kind cluster. A server built without the tag refuses a pool of one of these
+kinds, in the config file and through the API, with an error that names the
+tag.
+
+To use an experimental adapter, build the server yourself:
+
+```sh
+go build -tags experimental_adapters ./cmd/differently-server
+# or the image:
+docker build -f build/server.Dockerfile --build-arg GO_TAGS=experimental_adapters \
+ -t registry.example.com/differently-server:experimental .
+```
+
+With the Helm chart, point `server.image` at that image and set
+`server.experimentalAdapters=true`; otherwise the chart refuses pools of
+these kinds. The dashboard and the console offer only the default kinds, so
+add an experimental pool in the config file, with
+`differently admin pool add` or with `POST /v1/admin/pools`.
-### Built-in adapters
+### In the default build
| Provider | Status | Trust |
|---|---|---|
| [OpenSandbox](opensandbox.md) `opensandbox` | **Verified live**: Docker and Kubernetes runtimes, 2026-10-29 | declared |
-| [agent-sandbox](agent-sandbox.md) `agent-sandbox` | **Verified live**: kind cluster, runc, 2026-10-28 | declared |
| [Daytona](daytona.md) `daytona` | **Verified live**: container class, 2026-10-29 (VM class not run) | external |
| [Freestyle](freestyle.md) `freestyle` | **Verified live**: default snapshot, 2026-10-29 | external |
+
+### Experimental: build tag `experimental_adapters`
+
+| Provider | Status | Trust |
+|---|---|---|
+| [agent-sandbox](agent-sandbox.md) `agent-sandbox` | **Verified live**: kind cluster, runc, 2026-10-28 | declared |
| [E2B](e2b.md) `e2b` | Tested with a fake server | declared |
| [Boat](boat.md) `boat` | Tested with a fake server | external |
| [Morph Cloud](morph.md) `morph` | Tested with a fake server | external |
@@ -57,26 +89,27 @@ Cloud, Northflank BYOC).
"Keeps state" is stop/start that keeps the disk, or pause that keeps memory.
"Snapshots" is what a snapshot captures; "+ fork" means a new sandbox can
-start from one.
-
-| Provider | Isolation | Keeps state | Snapshots | Port URLs |
-|---|---|---|---|---|
-| OpenSandbox | container or microVM | stop/start or pause, by runtime | disk + fork | signed |
-| agent-sandbox | RuntimeClass | stop/start (volume) | — | in-cluster |
-| Daytona | container or VM | stop/start; pause on VMs | disk + fork (memory on VMs) | signed |
-| Freestyle | microVM | stop/start, pause | memory + fork | public |
-| E2B | microVM | pause | memory + fork | public, opt-in |
-| Boat | VM | stop/start | disk + fork | token or public |
-| Morph Cloud | VM | pause | memory + fork | public |
-| Vercel Sandbox | microVM | stop/start | disk + fork | public |
-| Fly.io Machines | microVM | stop/start, suspend | — | — |
-| Blaxel | microVM | auto standby | — | token |
-| CubeSandbox | microVM | pause | memory + fork | opt-in |
-| AWS AgentCore | microVM | — | — | — |
-| Azure Sandboxes | managed | stop/start | disk + fork | public |
-| Azure sessions | Hyper-V | — | — | — |
-| Google Cloud sandboxes | container | stop/start | memory + fork | — |
-| Northflank | microVM or gVisor | — | — | public |
+start from one. "Build" is `default` for the adapters in every server and
+`tag` for the experimental ones.
+
+| Provider | Build | Isolation | Keeps state | Snapshots | Port URLs |
+|---|---|---|---|---|---|
+| OpenSandbox | default | container or microVM | stop/start or pause, by runtime | disk + fork | signed |
+| agent-sandbox | tag | RuntimeClass | stop/start (volume) | — | in-cluster |
+| Daytona | default | container or VM | stop/start; pause on VMs | disk + fork (memory on VMs) | signed |
+| Freestyle | default | microVM | stop/start, pause | memory + fork | public |
+| E2B | tag | microVM | pause | memory + fork | public, opt-in |
+| Boat | tag | VM | stop/start | disk + fork | token or public |
+| Morph Cloud | tag | VM | pause | memory + fork | public |
+| Vercel Sandbox | tag | microVM | stop/start | disk + fork | public |
+| Fly.io Machines | tag | microVM | stop/start, suspend | — | — |
+| Blaxel | tag | microVM | auto standby | — | token |
+| CubeSandbox | tag | microVM | pause | memory + fork | opt-in |
+| AWS AgentCore | tag | microVM | — | — | — |
+| Azure Sandboxes | tag | managed | stop/start | disk + fork | public |
+| Azure sessions | tag | Hyper-V | — | — | — |
+| Google Cloud sandboxes | tag | container | stop/start | memory + fork | — |
+| Northflank | tag | microVM or gVisor | — | — | public |
Every adapter offers exec and files. Streamed exec, stdin, egress control and
size limits differ: each page has the full table.
@@ -132,23 +165,38 @@ would wrap.
image or request, `insufficient_capacity` for 429 and capacity errors, and
`provider_unavailable` for outages and rejected credentials. Advertise
only features that work.
-2. Register it:
- - the factory in `internal/wiring/wiring.go`;
- - `KnownAdapters` in `internal/config/config.go`, and **exactly one**
- trust list there: `externalKinds` for a managed provider (its pools are
- always `external`), or `declaredTrustKinds` for a self-hostable runtime
- (its pools must say who operates it). `TestEveryAdapterKindHasATrustRule`
- fails until you choose, so a new kind can never be treated as private
- by omission;
- - the adapter enum in `deploy/helm/differently/values.schema.json`;
- - the `--adapter` help in `cmd/differently/main.go` and the `kinds`
- table in `internal/dashboard/static/app.js`, so operators can add it
- without a config file.
+2. Register it. A new adapter starts as experimental, behind the build tag:
+ - `//go:build experimental_adapters` as the first line of every file in
+ the package, tests included;
+ - the factory in `internal/wiring/wiring_experimental.go`;
+ - `ExperimentalAdapters` in `internal/config/config.go`, and **exactly
+ one** trust list there: `externalKinds` for a managed provider (its
+ pools are always `external`), or `declaredTrustKinds` for a
+ self-hostable runtime (its pools must say who operates it).
+ `TestEveryAdapterKindHasATrustRule` fails until you choose, so a new
+ kind can never be treated as private by omission, and
+ `TestRegisteredKindsMatchConfig` fails if the factory and the list
+ disagree;
+ - the adapter enum in `deploy/helm/differently/values.schema.json` and
+ the experimental list in the chart's `differently.validate` helper
+ (`templates/_helpers.tpl`).
+
+ `make experimental` builds, vets and tests with the tag; `make verify`
+ and CI run it. Adapters written in parallel all edit these lists, so
+ their PRs conflict with each other. The resolution is always the union
+ of both sides.
+
+ Moving an adapter into the default build is a separate change and a
+ maintainers' decision; it needs **Verified live** status first. Drop the
+ build tag, move the factory to `internal/wiring/wiring.go` and the kind to
+ `DefaultAdapters`, and add it to the `--adapter` help in
+ `cmd/differently/main.go`, the `kinds` table in
+ `internal/dashboard/static/app.js` and `console/src/lib/providers.ts`.
3. Write `docs/providers/.md` from the template below, and add a row
here.
4. When you have an account or a running install, run the adapter's live
- test (`go test -tags live`, as `agent-sandbox`, `daytona` and `freestyle`
- have) and drive a pool on it through a Differently server, or run the
+ test (`go test -tags live,experimental_adapters`; `daytona` and
+ `freestyle` have one to follow) and drive a pool on it through a Differently server, or run the
[end-to-end suite](../../test/e2e/README.md) against it where it fits.
Record what ran on the page. Until then, the status stays
**Tested with a fake server**.
@@ -184,7 +232,7 @@ sandboxes go with `differently admin pool use `.
| | |
|---|---|
| Status | one of the four statuses, with the date and what ran |
-| Adapter | `` (`internal/adapters/`), or none |
+| Adapter | `` (`internal/adapters/`), or none; say if it is experimental (build tag) |
| Trust | external (provider-managed) or declared (self-hostable) |
| Isolation | what the provider documents (microVM, gVisor, container…) |
| API checked | docs or OpenAPI URL, and the date read |
diff --git a/docs/providers/agent-sandbox.md b/docs/providers/agent-sandbox.md
index eca9c90..e114f2e 100644
--- a/docs/providers/agent-sandbox.md
+++ b/docs/providers/agent-sandbox.md
@@ -1,5 +1,10 @@
# agent-sandbox (Kubernetes)
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Verified live** (2026-10-28): the adapter's live test and a Differently server, against agent-sandbox v1.0.8 on a single-node kind cluster (Kubernetes 1.37, runc, arm64) |
@@ -192,7 +197,8 @@ template and rejects `image`, `runtimeClassName`, `volumeSize`,
lists, exec arguments and exit codes, stdin, streaming, files, stop/start,
warm-pool claims, token rotation and that credentials are not echoed.
`kube_test.go` covers the WebSocket framing.
-- **Live** (2026-10-28, `go test -tags live -run TestLiveAgentSandbox`):
+- **Live** (2026-10-28, `go test -tags live -run TestLiveAgentSandbox`; the
+ test now also needs `-tags live,experimental_adapters`):
agent-sandbox v1.0.8 (`registry.k8s.io/agent-sandbox/agent-sandbox-controller:v1.0.8`,
`sandbox-with-extensions.yaml`) on kind v0.33.0 (Kubernetes 1.37.0,
containerd 2.3.4, runc) on an arm64 Colima VM, with a service account
diff --git a/docs/providers/aws-agentcore.md b/docs/providers/aws-agentcore.md
index b7b749f..9174a4f 100644
--- a/docs/providers/aws-agentcore.md
+++ b/docs/providers/aws-agentcore.md
@@ -1,5 +1,10 @@
# Amazon Bedrock AgentCore Code Interpreter
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (implemented from the published API, not run against AWS) |
diff --git a/docs/providers/azure-sandboxes.md b/docs/providers/azure-sandboxes.md
index fda74ec..073ba9d 100644
--- a/docs/providers/azure-sandboxes.md
+++ b/docs/providers/azure-sandboxes.md
@@ -1,5 +1,10 @@
# Azure Container Apps Sandboxes
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (implemented from the published API, not run against Azure) |
diff --git a/docs/providers/azure-sessions.md b/docs/providers/azure-sessions.md
index 20536e0..5b432f2 100644
--- a/docs/providers/azure-sessions.md
+++ b/docs/providers/azure-sessions.md
@@ -1,5 +1,10 @@
# Azure Container Apps dynamic sessions
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** for Python code-interpreter pools (implemented from the published API, not run against Azure). Custom container and shell pools: **Not supported** by this adapter (see [below](#custom-container-and-shell-pools)) |
diff --git a/docs/providers/blaxel.md b/docs/providers/blaxel.md
index 63951ec..2de063a 100644
--- a/docs/providers/blaxel.md
+++ b/docs/providers/blaxel.md
@@ -1,5 +1,10 @@
# Blaxel
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (implemented from Blaxel's published OpenAPI specs, not run against Blaxel) |
diff --git a/docs/providers/boat.md b/docs/providers/boat.md
index c71a703..21dc735 100644
--- a/docs/providers/boat.md
+++ b/docs/providers/boat.md
@@ -1,5 +1,10 @@
# Boat
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (2026-10-28): implemented from Boat's published API, not run against Boat |
diff --git a/docs/providers/cubesandbox.md b/docs/providers/cubesandbox.md
index 31fe49c..f926502 100644
--- a/docs/providers/cubesandbox.md
+++ b/docs/providers/cubesandbox.md
@@ -1,5 +1,10 @@
# CubeSandbox
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (implemented from CubeSandbox's API source and OpenAPI document, not run against CubeSandbox; see [How it was tested](#how-it-was-tested)) |
@@ -122,8 +127,8 @@ With Differently's Helm chart, server egress is restricted by default
field (proto3 JSON), a missing binary (127), a timeout (124), stdin,
streaming, files, host and path port URLs, health with a missing template
and a rejected key.
-- **Live:** not run. `live_test.go` (`go test -tags live -run
- TestLiveCubeSandbox`, settings in the file) is ready for a host. The
+- **Live:** not run. `live_test.go` (`go test -tags live,experimental_adapters
+ -run TestLiveCubeSandbox`, settings in the file) is ready for a host. The
one-click install needs a dedicated Linux host with `/dev/kvm`, at least
7.5 GiB of memory (its preflight), XFS at `/data/cubelet`, about 50 GB of
disk, root and Docker. On arm64 the bundle is installed by hand, and
diff --git a/docs/providers/e2b.md b/docs/providers/e2b.md
index 36bdaf6..25d601b 100644
--- a/docs/providers/e2b.md
+++ b/docs/providers/e2b.md
@@ -1,5 +1,10 @@
# E2B
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (2026-10-28): implemented from E2B's published API specs, not run against E2B |
diff --git a/docs/providers/fly.md b/docs/providers/fly.md
index 66b28a2..0f35ba8 100644
--- a/docs/providers/fly.md
+++ b/docs/providers/fly.md
@@ -1,5 +1,10 @@
# Fly.io Machines
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server**: implemented from the Machines API OpenAPI document, not run against a Fly.io account |
diff --git a/docs/providers/gcp-sandboxes.md b/docs/providers/gcp-sandboxes.md
index 75d117a..a092b58 100644
--- a/docs/providers/gcp-sandboxes.md
+++ b/docs/providers/gcp-sandboxes.md
@@ -1,5 +1,10 @@
# Google Cloud Agent Platform sandboxes
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (implemented from the published API, not run against Google Cloud) |
diff --git a/docs/providers/morph.md b/docs/providers/morph.md
index 97289e5..398cfa1 100644
--- a/docs/providers/morph.md
+++ b/docs/providers/morph.md
@@ -1,5 +1,10 @@
# Morph Cloud
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (2026-10-28): implemented from Morph Cloud's published API, not run against Morph Cloud |
diff --git a/docs/providers/northflank.md b/docs/providers/northflank.md
index 0fb2275..edbe0bc 100644
--- a/docs/providers/northflank.md
+++ b/docs/providers/northflank.md
@@ -1,5 +1,10 @@
# Northflank
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server** (implemented from the published API, not run against Northflank) |
diff --git a/docs/providers/opensandbox.md b/docs/providers/opensandbox.md
index 500ac6f..c64668b 100644
--- a/docs/providers/opensandbox.md
+++ b/docs/providers/opensandbox.md
@@ -33,8 +33,10 @@ installation guide is [OpenSandbox pools](../guides/opensandbox-pools.md).
created from a Fast Sandbox template, pause that keeps memory, disk snapshots
and fork, and OpenSandbox's own idle pause with wake on access. Run live
on 2026-10-29 (single node, nested KVM) and recorded; OpenWork's Workbot
-desktop streamed through it. Pause is slow: a full checkpoint upload, about
-3 minutes for a 4 GiB desktop; resume and wake take under a second
+desktop streamed through it. Pause is slow: a full checkpoint, read back and
+uploaded, about 3.5 minutes for a 4 GiB desktop (about 3 with sparse
+templates, [shortening pauses](../guides/opensandbox-pools.md#shortening-pauses));
+resume and wake take under two seconds
([setup, timings and gaps](../guides/opensandbox-pools.md#fast-sandbox-runtime-microvms-pause-with-memory-native-idle)).
## Configure
@@ -106,5 +108,7 @@ default `https` with signed ports, else `http`) and `portURLSeconds`
as `fastsandbox-1.2.0`, and OpenWork's Workbot desktop through
Differently: create, viewer streamed over a port URL, pause and resume
keeping memory, idle pause and wake by a port request.
-- Not run: the end-to-end suite on Fast Sandbox, multi-node clusters and
- node drains, managed EKS, GKE and AKS.
+- The end-to-end suite on a Fast Sandbox pool, on two-node k3s with a
+ Standard_D16s_v6 KVM node, 2026-10-29: 14 of 14 run passed
+ ([azure-vms.md](../deploy/azure-vms.md#verify)).
+- Not run: node drains, more than one KVM node, managed EKS, GKE and AKS.
diff --git a/docs/providers/vercel.md b/docs/providers/vercel.md
index ac1ba69..304860d 100644
--- a/docs/providers/vercel.md
+++ b/docs/providers/vercel.md
@@ -1,5 +1,10 @@
# Vercel Sandbox
+> **Experimental adapter.** It is not in the default `differently-server`
+> build or the published image. It needs a server built with
+> `-tags experimental_adapters`: see
+> [what the server contains](README.md#what-the-server-contains).
+
| | |
|---|---|
| Status | **Tested with a fake server**: implemented from Vercel's REST API reference and OpenAPI document, not run against a Vercel account |
diff --git a/docs/research/fast-sandbox-multipart-upload.patch b/docs/research/fast-sandbox-multipart-upload.patch
new file mode 100644
index 0000000..cfed53f
--- /dev/null
+++ b/docs/research/fast-sandbox-multipart-upload.patch
@@ -0,0 +1,406 @@
+Fast Sandbox: publish large artifacts as a parallel multipart upload
+
+Base: opensandbox-group/fast-sandbox main at 3d804c0 (2026-10-29), the code
+OpenSandbox 1.2.0 ships for these files. Apply with `git apply`.
+
+The node agent uploads each artifact as one streamed PUT, so the store
+handles a 14 GB checkpoint on a single connection. Files of 256 MiB or more
+now go up as a multipart upload with four 64 MiB parts in flight; each part
+retries like a PUT, and a failed upload is aborted so the store drops its
+parts (the single-PUT retries left orphaned temporary objects on RustFS).
+signWrite now signs the request's own method, as the multipart POST and
+DELETE need. Measured against Garage 2.4.1 from RAM: 285 MB/s as one PUT,
+644 MB/s as four parallel parts. See
+docs/research/fast-sandbox-pause-time.md in different-ai/differently.
+
+diff --git a/internal/runtime/firecracker/agent/multipart_test.go b/internal/runtime/firecracker/agent/multipart_test.go
+new file mode 100644
+index 0000000..2461c36
+--- /dev/null
++++ b/internal/runtime/firecracker/agent/multipart_test.go
+@@ -0,0 +1,141 @@
++package agent
++
++import (
++ "bytes"
++ "fmt"
++ "io"
++ "net/http"
++ "net/http/httptest"
++ "os"
++ "path/filepath"
++ "sort"
++ "strconv"
++ "strings"
++ "sync"
++ "testing"
++ "time"
++
++ "github.com/stretchr/testify/require"
++)
++
++// fakeMultipartStore serves PUT objects and the multipart calls (initiate,
++// part, complete, abort), recording how many parts were in flight at once.
++type fakeMultipartStore struct {
++ mu sync.Mutex
++ objects map[string][]byte
++ parts map[int][]byte
++ inFlight int
++ peak int
++ aborted bool
++ failPart int // respond 400 to this part number
++}
++
++func (s *fakeMultipartStore) handle(w http.ResponseWriter, r *http.Request) {
++ key := strings.Trim(r.URL.Path, "/")
++ query := r.URL.Query()
++ switch {
++ case r.Method == http.MethodPost && query.Has("uploads"):
++ _, _ = w.Write([]byte(`up-1 `))
++ case r.Method == http.MethodPut && query.Get("uploadId") == "up-1":
++ number, _ := strconv.Atoi(query.Get("partNumber"))
++ s.mu.Lock()
++ s.inFlight++
++ s.peak = max(s.peak, s.inFlight)
++ s.mu.Unlock()
++ time.Sleep(20 * time.Millisecond)
++ body, _ := io.ReadAll(r.Body)
++ s.mu.Lock()
++ s.inFlight--
++ fail := number == s.failPart
++ if !fail {
++ s.parts[number] = body
++ }
++ s.mu.Unlock()
++ if fail {
++ w.WriteHeader(http.StatusBadRequest)
++ return
++ }
++ w.Header().Set("ETag", fmt.Sprintf(`"etag-%d"`, number))
++ case r.Method == http.MethodPost && query.Get("uploadId") == "up-1":
++ body, _ := io.ReadAll(r.Body)
++ s.mu.Lock()
++ defer s.mu.Unlock()
++ numbers := make([]int, 0, len(s.parts))
++ for n := range s.parts {
++ if !bytes.Contains(body, []byte(fmt.Sprintf("%d "etag-%d" ", n, n))) {
++ w.WriteHeader(http.StatusBadRequest)
++ return
++ }
++ numbers = append(numbers, n)
++ }
++ sort.Ints(numbers)
++ var object []byte
++ for _, n := range numbers {
++ object = append(object, s.parts[n]...)
++ }
++ s.objects[key] = object
++ _, _ = w.Write([]byte(` `))
++ case r.Method == http.MethodDelete && query.Get("uploadId") == "up-1":
++ s.mu.Lock()
++ s.aborted = true
++ s.mu.Unlock()
++ w.WriteHeader(http.StatusNoContent)
++ case r.Method == http.MethodPut:
++ body, _ := io.ReadAll(r.Body)
++ s.mu.Lock()
++ s.objects[key] = body
++ s.mu.Unlock()
++ default:
++ w.WriteHeader(http.StatusMethodNotAllowed)
++ }
++}
++
++func shrinkMultipart(t *testing.T) {
++ t.Helper()
++ threshold, part, workers := multipartThreshold, multipartPartSize, multipartWorkers
++ multipartThreshold, multipartPartSize, multipartWorkers = 1024, 256, 3
++ t.Cleanup(func() { multipartThreshold, multipartPartSize, multipartWorkers = threshold, part, workers })
++}
++
++// A large artifact goes up as parallel parts and arrives byte-identical;
++// small files keep the single PUT.
++func TestPublishLargeArtifactUploadsPartsInParallel(t *testing.T) {
++ shrinkMultipart(t)
++ store := &fakeMultipartStore{objects: map[string][]byte{}, parts: map[int][]byte{}}
++ server := httptest.NewServer(http.HandlerFunc(store.handle))
++ defer server.Close()
++ client, err := NewClient("s3://bucket/publish", writeCredential(), WithEndpoint(server.URL))
++ require.NoError(t, err)
++ dir := stageArtifactSet(t)
++ rootfs := bytes.Repeat([]byte("0123456789abcdef"), 200) // 3200 bytes: 13 parts of 256
++ require.NoError(t, os.WriteFile(filepath.Join(dir, "rootfs.ext4"), rootfs, 0o644))
++
++ result, err := client.PublishImage(t.Context(), "", "app-v2", dir)
++ require.NoError(t, err)
++ base := strings.TrimSuffix(strings.TrimPrefix(result.ManifestRef, "s3://"), "/manifest.json")
++ require.Equal(t, rootfs, store.objects[base+"/rootfs.ext4"])
++ require.Equal(t, []byte("memory"), store.objects[base+"/memory.snap"], "small files keep the single PUT")
++ require.Len(t, store.parts, 13)
++ require.Greater(t, store.peak, 1, "parts upload in parallel")
++ require.False(t, store.aborted)
++}
++
++// A part the store refuses fails the publish and aborts the upload, so the
++// store does not keep orphaned parts.
++func TestPublishAbortsFailedMultipartUpload(t *testing.T) {
++ shrinkMultipart(t)
++ store := &fakeMultipartStore{objects: map[string][]byte{}, parts: map[int][]byte{}, failPart: 5}
++ server := httptest.NewServer(http.HandlerFunc(store.handle))
++ defer server.Close()
++ client, err := NewClient("s3://bucket/publish", writeCredential(), WithEndpoint(server.URL))
++ require.NoError(t, err)
++ dir := stageArtifactSet(t)
++ require.NoError(t, os.WriteFile(filepath.Join(dir, "rootfs.ext4"), bytes.Repeat([]byte{7}, 3000), 0o644))
++
++ _, err = client.PublishImage(t.Context(), "", "app-v2", dir)
++ require.ErrorContains(t, err, "part 5")
++ require.True(t, store.aborted)
++ for key := range store.objects {
++ require.NotContains(t, key, "rootfs.ext4", "a failed upload is never completed")
++ }
++}
+diff --git a/internal/runtime/firecracker/agent/publish.go b/internal/runtime/firecracker/agent/publish.go
+index e0bafe7..86aed2b 100644
+--- a/internal/runtime/firecracker/agent/publish.go
++++ b/internal/runtime/firecracker/agent/publish.go
+@@ -141,6 +141,9 @@ func (c *Client) putFile(ctx context.Context, dir, name, storeKey string) error
+ if err != nil {
+ return err
+ }
++ if info.Size() >= multipartThreshold {
++ return c.s3.putFileMultipart(ctx, path, storeKey, info.Size())
++ }
+ return c.s3.put(ctx, storeKey, info.Size(), func() (io.ReadCloser, error) {
+ return os.Open(path)
+ })
+diff --git a/internal/runtime/firecracker/agent/s3client.go b/internal/runtime/firecracker/agent/s3client.go
+index 129d7a8..5ca59ed 100644
+--- a/internal/runtime/firecracker/agent/s3client.go
++++ b/internal/runtime/firecracker/agent/s3client.go
+@@ -1,17 +1,21 @@
+ package agent
+
+ import (
++ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
++ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
++ "os"
+ "strconv"
+ "strings"
++ "sync"
+ "time"
+
+ "k8s.io/klog/v2"
+@@ -189,6 +193,179 @@ func (c *s3Client) put(ctx context.Context, storeKey string, size int64, open fu
+ return fmt.Errorf("PUT %s: %w", key, lastErr)
+ }
+
++// Large artifacts publish as a parallel multipart upload. One streamed PUT
++// keeps the store on a single connection, and with it on one core for its
++// hashing and compression; parts let it use several. Garage took a 4 GiB
++// memory snapshot at about 285 MB/s as one PUT and about 650 MB/s as four
++// parallel 64 MiB parts. Variables so tests can shrink them.
++var (
++ multipartThreshold int64 = 256 << 20
++ multipartPartSize int64 = 64 << 20
++ multipartWorkers = 4
++)
++
++// putFileMultipart uploads the file at path as a multipart upload with
++// multipartWorkers parts in flight. Each part is retried on its own like a
++// PUT; an upload that fails is aborted, so the store drops its parts.
++func (c *s3Client) putFileMultipart(ctx context.Context, path, storeKey string, size int64) error {
++ if !c.writable() {
++ return ErrNotWritable
++ }
++ key := storeKey
++ if c.prefix != "" {
++ key = c.prefix + "/" + storeKey
++ }
++ urlString, err := c.objectURL(key)
++ if err != nil {
++ return err
++ }
++ file, err := os.Open(path)
++ if err != nil {
++ return err
++ }
++ defer file.Close()
++
++ var uploadID string
++ err = c.retryWrite(ctx, key, func() error {
++ _, body, err := c.writeOnce(ctx, http.MethodPost, urlString+"?uploads=", nil, 0)
++ if err != nil {
++ return err
++ }
++ var initiated struct {
++ UploadID string `xml:"UploadId"`
++ }
++ if err := xml.Unmarshal(body, &initiated); err != nil || initiated.UploadID == "" {
++ return &httpError{StatusCode: http.StatusBadGateway, Body: "no UploadId in " + string(body)}
++ }
++ uploadID = initiated.UploadID
++ return nil
++ })
++ if err != nil {
++ return fmt.Errorf("PUT %s: start multipart upload: %w", key, err)
++ }
++ upload := "uploadId=" + awsQueryEscape(uploadID)
++
++ parts := int((size + multipartPartSize - 1) / multipartPartSize)
++ etags := make([]string, parts)
++ partCtx, cancel := context.WithCancel(ctx)
++ defer cancel()
++ var (
++ wg sync.WaitGroup
++ failOnce sync.Once
++ partErr error
++ )
++ next := make(chan int)
++ for range min(multipartWorkers, parts) {
++ wg.Add(1)
++ go func() {
++ defer wg.Done()
++ for i := range next {
++ offset := int64(i) * multipartPartSize
++ length := min(multipartPartSize, size-offset)
++ partURL := urlString + "?partNumber=" + strconv.Itoa(i+1) + "&" + upload
++ err := c.retryWrite(partCtx, key, func() error {
++ header, _, err := c.writeOnce(partCtx, http.MethodPut, partURL, io.NewSectionReader(file, offset, length), length)
++ etags[i] = header.Get("ETag")
++ return err
++ })
++ if err != nil {
++ failOnce.Do(func() { partErr = fmt.Errorf("part %d: %w", i+1, err); cancel() })
++ }
++ }
++ }()
++ }
++feed:
++ for i := range parts {
++ select {
++ case next <- i:
++ case <-partCtx.Done():
++ break feed
++ }
++ }
++ close(next)
++ wg.Wait()
++ if partErr == nil {
++ partErr = ctx.Err()
++ }
++ if partErr == nil {
++ var complete bytes.Buffer
++ complete.WriteString("")
++ for i, etag := range etags {
++ fmt.Fprintf(&complete, "%d ", i+1)
++ _ = xml.EscapeText(&complete, []byte(etag))
++ complete.WriteString(" ")
++ }
++ complete.WriteString(" ")
++ partErr = c.retryWrite(ctx, key, func() error {
++ _, body, err := c.writeOnce(ctx, http.MethodPost, urlString+"?"+upload, bytes.NewReader(complete.Bytes()), int64(complete.Len()))
++ if err == nil && bytes.Contains(body, []byte("")) {
++ // S3 may answer 200 and report the failure in the body.
++ return &httpError{StatusCode: http.StatusInternalServerError, Body: string(body)}
++ }
++ return err
++ })
++ }
++ if partErr != nil {
++ abortCtx, cancelAbort := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
++ defer cancelAbort()
++ _, _, _ = c.writeOnce(abortCtx, http.MethodDelete, urlString+"?"+upload, nil, 0)
++ return fmt.Errorf("PUT %s: %w", key, partErr)
++ }
++ return nil
++}
++
++// retryWrite runs one write with put's policy: transport errors and 5xx
++// responses are retried with exponential backoff, 4xx responses are not.
++func (c *s3Client) retryWrite(ctx context.Context, key string, write func() error) error {
++ backoff := c.retryDelay
++ var lastErr error
++ for attempt := 0; attempt <= s3RetryAttempts; attempt++ {
++ if attempt > 0 {
++ select {
++ case <-time.After(backoff):
++ case <-ctx.Done():
++ return ctx.Err()
++ }
++ backoff *= 2
++ }
++ err := write()
++ if err == nil {
++ return nil
++ }
++ var status *httpError
++ if errors.As(err, &status) && status.StatusCode < http.StatusInternalServerError {
++ return err
++ }
++ if ctx.Err() != nil {
++ return ctx.Err()
++ }
++ klog.V(2).InfoS("S3 write attempt failed, retrying", "key", key, "attempt", attempt+1, "err", err)
++ lastErr = err
++ }
++ return lastErr
++}
++
++// writeOnce sends one request signed with the write credential and returns
++// the response headers and its (bounded) body.
++func (c *s3Client) writeOnce(ctx context.Context, method, urlString string, body io.Reader, size int64) (http.Header, []byte, error) {
++ request, err := http.NewRequestWithContext(ctx, method, urlString, body)
++ if err != nil {
++ return nil, nil, err
++ }
++ request.ContentLength = size
++ c.signWrite(request)
++ response, err := c.putHTTP.Do(request)
++ if err != nil {
++ return nil, nil, err
++ }
++ defer response.Body.Close()
++ payload, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
++ if response.StatusCode < 200 || response.StatusCode >= 300 {
++ return response.Header, nil, &httpError{StatusCode: response.StatusCode, Body: string(payload)}
++ }
++ return response.Header, payload, nil
++}
++
+ // putOnce performs a single signed PUT. The payload is signed as
+ // UNSIGNED-PAYLOAD so multi-GiB artifacts stream from disk without a second
+ // read for the content hash; Content-Length still carries the exact size.
+@@ -461,9 +638,11 @@ func deriveSigningKey(secretKey, date, region string) []byte {
+ return hmacSHA256(key, "aws4_request")
+ }
+
+-// signWrite applies AWS Signature Version 4 to a PUT with the write
+-// credential. The payload hash is UNSIGNED-PAYLOAD: streamed bodies cannot
+-// promise a content hash header without a second full read.
++// signWrite applies AWS Signature Version 4 to a write (a PUT, or a
++// multipart POST or DELETE) with the write credential. The payload hash is
++// UNSIGNED-PAYLOAD: streamed bodies cannot promise a content hash header
++// without a second full read. The query must already be canonical (sorted,
++// encoded).
+ func (c *s3Client) signWrite(request *http.Request) {
+ now := time.Now().UTC()
+ amzDate := now.Format("20060102T150405Z")
+@@ -481,7 +660,7 @@ func (c *s3Client) signWrite(request *http.Request) {
+ "x-amz-date:" + amzDate + "\n"
+ signedHeaders := "host;x-amz-content-sha256;x-amz-date"
+ canonicalRequest := strings.Join([]string{
+- http.MethodPut,
++ request.Method,
+ canonicalURI,
+ request.URL.RawQuery,
+ canonicalHeaders,
diff --git a/docs/research/fast-sandbox-pause-time.md b/docs/research/fast-sandbox-pause-time.md
new file mode 100644
index 0000000..5f1e85e
--- /dev/null
+++ b/docs/research/fast-sandbox-pause-time.md
@@ -0,0 +1,263 @@
+# Fast Sandbox pause time (2026-10-30)
+
+Question: where do the three minutes of a Workbot desktop's pause on Fast
+Sandbox go, and how much of it can we remove? On a pool that checkpoints, a
+paused sandbox gives its slot back only when the pause finishes
+([capacity.md](../scheduling/capacity.md)), so pause time is capacity.
+
+**Result.** Every phase of the pause is limited by the state-root disk
+(about 175 MB/s, reads and writes together) and, once the disk is spared, by
+the artifact store on one connection. Leaving the template's zero runs as
+holes, a Fast Sandbox fix this page carries as a patch and an operator can
+apply by hand meanwhile, cut the median pause from 215.6 s to 179.6 s over
+eight cycles (−17%; the first pause after a create −45%) with resumes as
+fast and the desktop's state intact. Halving every pause needs Fast Sandbox
+changes: two more are written as patches or proposals with expected gains
+[below](#remaining-ideas-that-need-an-upstream-change).
+
+Legend: **Measured** = run on the test host below; **Read** = from Fast
+Sandbox's source at `main` 3d804c0, the code OpenSandbox 1.2.0 ships for
+these paths; **Estimated** = derived from measured rates, not run.
+
+## Setup
+
+| | |
+|---|---|
+| Host | Azure `Standard_D8s_v6` (8 vCPU, 32 GiB), single-node k3s v1.36.5, nested KVM |
+| State root | XFS (reflink) on a Premium SSD **P6** (64 GiB): 50 MB/s, bursting to 170 MB/s on credits. Production's KVM node uses a P15 (125 MB/s, bursting to 170 MB/s) and saw 174–186 s pauses ([azure-vms.md](../deploy/azure-vms.md#verify)) |
+| Runtime | OpenSandbox 1.2.0, Fast Sandbox (Firecracker v1.16.1, jailed), Garage v2.4.1 on the same disk (zstd level 1, 1 MiB blocks, replication 1) |
+| Template | Workbot's desktop image: 2 vCPU, 4 GiB RAM, 9 GiB root filesystem; no balloon device |
+| Control plane | Differently `pre-release` at bce0398, pool with `lifecycle.pause-checkpoint` |
+| Harness | [test/pause](../../test/pause/README.md): pause and resume through Differently's API, phases from the controller, fastlet, runtime agent and Garage logs, disk and CPU sampled every second, guest state compared before and after |
+
+An **idle** desktop is paused as created. An **in-use** one runs Xvfb,
+LibreOffice, Chromium and a process holding 384 MiB of random data, has
+300 MiB of random data and a copy of `/usr/share/doc` on disk and most of
+`/usr` in its page cache, and gets 64 MiB of new files before every later
+pause.
+
+## How a pause works (Read)
+
+1. The fastlet reflinks the sandbox's root filesystem into a staging
+ directory, pauses the VM and asks Firecracker for a full snapshot: device
+ state plus every page of guest memory, written to the state root and
+ fsynced. Then it **resumes the VM**.
+2. It hashes every staged file (SHA-256) to write `SHA256SUMS` and the
+ checkpoint manifest. The manifest's digest names the upload's directory.
+3. The node agent uploads `rootfs.ext4`, `vmstate.snap`, `memory.snap`, the
+ egress state, `SHA256SUMS` and the manifest, one streamed PUT each.
+4. The controller records the checkpoint, deletes the (still running)
+ runtime and marks the sandbox Paused. Only now does Differently see the
+ pause finish.
+
+A checkpoint is the full logical size every time: 13.96 GB for this
+template (9 GiB disk, 4 GiB memory), whatever the guest changed. Garage
+deduplicates it to what changed (34 MB for an idle desktop, about 2 GB in
+use), but the node still reads, hashes and streams all of it.
+
+## Before: where the time goes (Measured)
+
+Five pauses with the disk on burst credits, each on a fresh or just-resumed
+desktop: an idle one paused three times, an in-use one twice (its third ran
+out of burst credits and is listed apart). Medians, with the range:
+
+| Phase | Idle (3) | In use (2) | Disk traffic (median) |
+|---|---:|---:|---|
+| Request to the node | < 0.1 s | < 0.1 s | |
+| Root filesystem reflink, VM paused | < 0.1 s | 1.0 s | |
+| Memory dump (Firecracker, fsynced) | 43.4 s (25.6–49.0) | 27.8 s (26.0–29.7) | 4.3 GB written, plus 0.5–3.2 GB of guest pages faulted in |
+| Checksum and manifest | 80.6 s (80.5–80.8) | 80.9 s (80.6–81.2) | 14.0 GB read at 175 MB/s |
+| Upload (rootfs / memory) | 81.7 s (68 / 14) | 106.7 s (82 / 25) | 9.1–9.7 GB read again; Garage 131–145 CPU s |
+| Metadata commit to `paused` | 1.4 s | 1.8 s | |
+| **Pause, as Differently sees it** | **207.3 s** (189.8–230.6) | **218.6 s** (215.6–221.5) | |
+| Garage's new data | 10 MB | 0.6–2.0 GB | of 13.96 GB logical |
+
+Resume took 1.67 s (median) and the first command answered 0.03 s later.
+The in-use pause that ran out of burst credits took **340 s**: its upload
+read at 50 MB/s. Yesterday's five desktop pauses on the same host, read
+from the Fast Sandbox logs, split the same way (dump 25–27 s, checksum
+about 80 s, upload 71–92 s), and production's P15 node reported 174–186 s.
+
+What limits each phase:
+
+- **Memory dump (25–49 s): disk writes.** 4.3 GB at the disk's limit. It
+ is slower when the guest's untouched pages are no longer in the host's
+ page cache: a restored VM maps its template's (or last checkpoint's)
+ memory file, which is dense on disk, so the dump faults gigabytes of zero
+ pages back in from the same disk.
+- **Checksum (81 s): disk reads.** The whole 13.96 GB is read back at
+ 173 MB/s, the disk's burst ceiling. Even the memory just dumped usually
+ comes from disk: Firecracker runs inside the fastlet's memory cgroup
+ (8448 Mi for two desktops), whose limit pushes the dump out of the page
+ cache.
+- **Upload (81–108 s): Garage and the disk.** Garage spends about 130 CPU
+ seconds per pause on one connection (about 1.6 cores); whatever the
+ checksum pass's page cache no longer holds is read from disk a second
+ time.
+- **Metadata commit (1–3 s)** and the hop from Differently to the node
+ (under 0.1 s) do not matter.
+
+Reads and writes share one limit: every second of the run moved about
+175 MB through the disk, whatever the mix. On a P6 that is the burst
+ceiling; once the burst credits are gone it drops to 50 MB/s, and these
+phases would take three times as long.
+
+## Candidates (ranked by expected gain and risk)
+
+Expected gains are against the in-use baseline (about 220 s). "Safe" means
+a resumed sandbox gets the same memory and disk, and a crash at any point
+leaves either the old running sandbox or a complete checkpoint.
+
+| # | Candidate | Lives in | Expected gain | Safe? | Done here |
+|---:|---|---|---|---|---|
+| 1 | **Sparse templates**: write a pulled template's zero runs as holes, so reflinked roots, staged checkpoints and the memory a VM maps are sparse | Fast Sandbox (node agent pull); stand-in: punch the holes into the cached template | **Measured:** median pause −22% idle, −17% in use; the first pause after a create −45% | Yes: bytes and digests unchanged, the pull still verifies every file | [Patch](fast-sandbox-sparse-pull.patch) with tests; stand-in measured below |
+| 2 | **Parallel multipart upload** | Fast Sandbox (node agent) | Upload about 2× where the store is the limit: Garage took 285 MB/s in one PUT, 644 MB/s as four parallel parts and 693 MB/s as eight (from RAM) | Yes: same object; failed uploads are aborted, so no orphaned parts | [Patch](fast-sandbox-multipart-upload.patch) with tests; store measured, not deployed |
+| 3 | **Hash while uploading**: one read of the checkpoint instead of two | Fast Sandbox (fastlet and agent) | Removes the checksum pass as a separate read: the remaining checksum phase after #1 | Yes, if checkpoint uploads stop being named by the manifest's digest; the pull already resolves files next to the manifest it is given | Proposal |
+| 4 | **Finish the pause at the local commit** and upload in the background | Fast Sandbox (controller and agent) | Pause ≈ dump + manifest: about 30 s, a few seconds with a RAM spill | Changes durability: until the upload ends only the node holds the checkpoint (on these installs the store is on the same disk anyway); a resume on another node must wait for the upload | Proposal |
+| 5 | **Incremental checkpoints** against the template or the last checkpoint | Fast Sandbox and Firecracker (diff snapshots, reflink-aware disk diffs) | Bytes in proportion to what changed: an idle desktop's pause would write little more than its dirty pages | Needs chains and garbage collection | Proposal (Fast Sandbox issue #56 tracks diff snapshots) |
+| 6 | **Faster state root** | Infrastructure | Every disk-bound phase scales with throughput: a disk at 600 MB/s would cut the dump and checksum about 3.5× | Yes | Not done: new spend |
+| 7 | **Smaller guest**: 3 GiB memory, 6 GiB disk | Template (a product decision for Workbot) | Streamed bytes −25% (13.96 → about 10.5 GB); less once #1 makes unused disk free | Yes; less room for the desktop's apps and files | Not done |
+| 8 | **Dump into a RAM spill area** (`FAST_SANDBOX_SNAPSHOT_SPILL_DIR`) | Fast Sandbox configuration | Dump 26–49 s → 1.6 s **measured**, but the pause total did not move (210 s): the memory still reaches the disk while the checksum pass reads | Yes, with the fastlet's memory overhead raised by one dump (below) | **Measured**, then reverted |
+| 9 | **Drop the guest page cache or inflate a balloon** before the dump | Differently (a command through execd) or the template | None on time: the dump writes all of guest RAM whatever it holds, and this template has no balloon device, so freed pages keep their contents | Yes, but the desktop resumes with a cold cache | Not done (reasoned from the dump path) |
+| 10 | **Skip zero and free pages** in the dump | Firecracker / Fast Sandbox | Small after #1: the dump's writes are the full RAM; with #1 the reads already skip zeros | Yes | Not done |
+| 11 | **zstd level, parallel compression or none** in Garage | Garage configuration | Small: Garage compresses only blocks it does not hold, yet spent about the same CPU on an idle pause (34 MB new) as an in-use one (2 GB new). Its time goes to hashing every byte on one connection, which #2 spreads | Yes | Not done (measured as not the limit) |
+
+**Differently itself** does not sit in the pause path beyond one call and a
+0.5 s poll (request to node: under 0.1 s, commit back to `paused`: 1–3 s).
+Its share is to measure: `differently_pause_seconds` times every pause it
+asks for, so capsim's `PauseTime` can be checked against production.
+
+**The RAM spill needs memory headroom.** Firecracker runs inside the
+fastlet's memory cgroup (two 4 GiB desktops plus 256 MiB, 8448 Mi here), and
+a dump into a memory-backed volume is charged to it. Raise the Firecracker
+binding's `fastletOverhead` in `fast-sandbox-runtime-environments` by one
+dump (about 4.6 GiB for a 4 GiB VM; Fast Sandbox runs one dump at a time per
+fastlet) and size the volume to match, or two busy VMs and a dump can exceed
+the limit.
+
+## After (Measured)
+
+The template's zero runs punched into holes on the node (the stand-in for
+the sparse-pull patch, checked against the manifest's SHA-256), nothing else
+changed: Fast Sandbox's configuration, the fastlet's 8448 Mi limit and the
+dense checkpoints it writes are as in production. Eight pauses on burst
+credits:
+
+| | Idle (3) | In use (5) |
+|---|---:|---:|
+| **Pause median** | **161.6 s** (before 207.3 s, −22%) | **181.8 s** (before 218.6 s, −17%) |
+| **Pause p95** | **166.7 s** (before 230.6 s) | **216.1 s** (before 221.5 s) |
+| First pause after create | 114.3 s | 180.4 s |
+| Memory dump | 31.1 s | 27.6 s |
+| Checksum and manifest | 48.6 s | 51.9 s |
+| Upload | 79.2 s | 100.1 s |
+| Metadata commit | 1.9 s | 1.6 s |
+| Disk read, checksum / upload | 7.9 / 3.9 GB (from 14.0 / 9.1) | 8.4 / 4.9 GB (from 14.1 / 9.7) |
+| Disk busy during the upload | 40% | 64% |
+| **Resume median / p95** | **1.78 / 1.81 s** (before 1.67 / 1.69 s) | **1.66 / 1.72 s** (before 1.68 / 1.69 s) |
+| Resume to first command | 1.80 s | 1.69 s |
+
+Over all eight, the median pause went from 215.6 s to 179.6 s (−17%) and the
+p95 from 230.6 s to 216.1 s; resumes did not slow down.
+
+**State kept, every cycle:** the same long-lived processes with the same PIDs
+and start times (Xvfb, LibreOffice, Chromium, execd, the guest init), a
+process's 384 MiB of random data in memory (checksum recomputed after the
+resume), a 256 MiB file in tmpfs (guest RAM), a 300 MiB file on disk, and
+the boot ID. The open files of those processes matched too, except that
+Chromium opened or closed one socket in two in-use cycles: the guest clock
+jumps forward at the resume, and its idle-connection timers fire.
+
+Why the gain is smaller than the bytes removed:
+
+- **The first pause after a create gains most** (207 → 114 s idle): the VM
+ maps the sparse template, so the dump faults no zeros in and the checksum
+ reads 3.6 GB instead of 14 GB. After a resume the VM maps the checkpoint's
+ memory file, which Firecracker wrote densely, so later pauses read 4 GB
+ more. Writing the dump sparsely is in the list below.
+- **The upload is now Garage's.** With the disk 40–64% busy, Garage hashed
+ 13.96 GB on one connection at about two cores. That is the parallel
+ multipart patch's case.
+- **An in-use desktop writes its own data** (about 0.3–2 GB of new blocks per
+ pause into Garage on the same disk).
+
+The RAM spill area, measured on its own before the sparse templates (one
+in-use cycle on burst credits): dump 1.6 s, pause 210.5 s.
+
+## Remaining ideas that need an upstream change
+
+With sparse templates in place, the pause is a fsynced memory dump (about
+25–36 s), a checksum pass that reads mostly written data (28–53 s) and an
+upload that Garage more than the disk limits (59–125 s; Garage at about two
+cores with the disk 20–64% busy). Each idea below needs a change in Fast Sandbox;
+gains are Estimated from those phase times.
+
+| Change | Where | Expected gain on the in-use pause | Status |
+|---|---|---|---|
+| Sparse pull: write pulled artifacts with holes | node agent (`cache.go`) | What the stand-in measured, on every node and after every pull, without an operator step | [Patch](fast-sandbox-sparse-pull.patch), tested |
+| Parallel multipart upload | node agent (`s3client.go`, `publish.go`) | Upload about 2.3× faster where the store limits it: −30 to −45 s | [Patch](fast-sandbox-multipart-upload.patch), tested |
+| Hash while uploading (one read) | fastlet (`snapshot_driver.go`) and node agent | The checksum pass's read disappears into the upload's: −25 to −45 s | Proposal: upload checkpoint files under a name from the snapshot ID and write `SHA256SUMS` and the manifest last; the pull already resolves files next to the manifest |
+| Write the dump sparse, into a spill area | fastlet (`copyFile` in the spill move) | The dump stops waiting on the disk (1.6 s measured with a RAM spill) and the next resume maps a sparse memory file: −20 to −30 s | Proposal; needs the fastlet memory headroom above |
+| Finish the pause at the local commit, upload behind it | controller and node agent | Pause ≈ dump + manifest: about 30 s, or a few seconds with the spill | Proposal; a resume on another node waits for the upload, and until then only the node holds the checkpoint |
+| Incremental checkpoints | Firecracker diff snapshots, reflink-aware disk diffs | Bytes in proportion to change: seconds for an idle desktop | Proposal (Fast Sandbox issue #56) |
+| Keep the VM stopped after a pause's dump | fastlet | Not pause time: frees the sandbox's CPU and memory at the dump instead of after the upload | Proposal. Differently's [`pausing` status](../scheduling/capacity.md#pausing) now keeps work off the VM in that window, and a resume calling a pause off relies on the VM staying up, so the fastlet would also have to unfreeze it |
+| Cancel a pause when its sandbox is deleted | fastlet | Not pause time: a deleted sandbox's checkpoint kept hashing and uploading for minutes here, holding the disk | Proposal |
+| Evict consumed checkpoints before templates | fastlet image GC (`images.go`) | Not pause time: it keeps resumes fast ([below](#resumes-that-miss-the-nodes-cache-measured)) | [Patch](fast-sandbox-shared-image-cache.patch), tested, running in production |
+
+## Resumes that miss the node's cache (Measured)
+
+The resume times on this page assume the checkpoint is still in the node's
+image cache. In production on 2026-10-30 and 2026-11-02, three resumes found
+it gone and took 117–138 s, around the two-minute budget the OpenWork runner
+had at the time:
+
+| Resume | `POST /resume` | Where the time goes |
+|---|---:|---|
+| Checkpoint cached | about 2 s | Root filesystem reflinked in milliseconds |
+| Checkpoint evicted | 117–138 s | About 74 s pulling 13.96 GB from the artifact store, then 46–63 s creating the instance's root filesystem |
+
+**Cause (Read, then matched against production).** The fastlets on a node share
+one image cache, but each ranks eviction by its own in-memory use counts, under
+a hard-coded 20 GiB limit that counts root filesystems only: two 9 GiB images.
+No checkpoint is ever referenced, because a paused sandbox has no state
+directory and a running one records its template. So the first fastlet to run
+its hourly collection deletes checkpoints it never used, the newest included.
+All three slow resumes followed the eviction of exactly the checkpoint they
+needed, matched by cache key (the SHA-256 of `checkpoint:sha256:`).
+
+**Fix.** [The patch](fast-sandbox-shared-image-cache.patch) has each boot leave a
+marker in its cache entry that every fastlet can read. Spent checkpoints go
+first, then other entries oldest first, templates last, and
+`FAST_SANDBOX_IMAGE_CACHE_LIMIT_GIB` sets the limit. Rebuilding its base commit
+reproduces OpenSandbox 1.2.0's fastlet byte for byte, so the patch is the only
+difference.
+
+**After, on production with a 90 GiB limit:** a desktop paused in 242 s and
+resumed in 1.14 s (root filesystem 1.9 ms) with its file intact, and a
+collection with 36 GiB cached, above the old limit, evicted nothing. The
+eviction order under a full cache is covered by unit tests only.
+
+Production pauses took 242–255 s that day, longer than the 174–186 s measured
+on this node earlier, so the pause work above still matters. Sparse templates
+are not applied there.
+
+## Capacity (capsim)
+
+`differently-capsim` now takes `-pause` and `-resume`, and the Workbot
+default pause is the measured 182 s. On four slots
+([capacity.md](../scheduling/capacity.md#5-what-it-buys-the-model)):
+
+| Users | Pause | `release` wait p95 | `preempt-idle` wait p95 |
+|---:|---:|---:|---:|
+| 8 | 216 s (before) | 88 s | 89 s |
+| 8 | 182 s (after) | 56 s | 56 s |
+| 8 | 90 s (with the upstream changes) | 9 s | 9 s |
+| 12 | 216 s (before) | 459 s | 277 s |
+| 12 | 182 s (after) | 439 s | 250 s |
+| 12 | 90 s (with the upstream changes) | 361 s | 158 s |
+
+A shorter pause cuts the wait directly only while the queue is light (eight
+users). At twelve users most of the wait is slots held by computers that are
+running or not yet idle; halving the pause there takes the p95 down by about
+a fifth under `release` and by about 40% with idle preemption, which is the
+bigger lever.
diff --git a/docs/research/fast-sandbox-shared-image-cache.patch b/docs/research/fast-sandbox-shared-image-cache.patch
new file mode 100644
index 0000000..f12630a
--- /dev/null
+++ b/docs/research/fast-sandbox-shared-image-cache.patch
@@ -0,0 +1,288 @@
+Fast Sandbox: evict from the shared image cache by what is on disk
+
+Base: opensandbox-group/fast-sandbox main at 7b28a0c (2026-10-27), the fastlet
+OpenSandbox 1.2.0 ships: rebuilding that commit with Go 1.25.14 reproduces the
+published binary byte for byte. Apply with `git apply`.
+
+Every fastlet on a node shares one image cache, but its GC ranked eviction by
+that fastlet's in-memory use counts, under a 20 GiB limit that holds two 9 GiB
+root filesystems. No checkpoint is ever referenced (a paused sandbox has no
+state directory and a running one records its template), so the first fastlet
+to collect deleted checkpoints it had never used, the newest included, and the
+next resume pulled 14 GB again. Boots now leave a marker in the cache entry
+that every fastlet can read: spent checkpoints go first, then other entries
+oldest first, templates last. FAST_SANDBOX_IMAGE_CACHE_LIMIT_GIB sets the limit.
+See docs/research/fast-sandbox-pause-time.md in different-ai/differently.
+
+diff --git a/internal/runtime/firecracker/driver.go b/internal/runtime/firecracker/driver.go
+index b5cc7ba..c6dfec7 100644
+--- a/internal/runtime/firecracker/driver.go
++++ b/internal/runtime/firecracker/driver.go
+@@ -124,7 +124,7 @@ func New(profile runtimecatalog.RuntimeProfile) (*Driver, error) {
+ waitSocket: waitForAPISocket, processes: make(map[string]Process),
+ checkStorage: checkStateRootStorage,
+ imageGCInterval: defaultImageGCInterval,
+- imageCacheLimitBytes: defaultImageCacheLimitBytes,
++ imageCacheLimitBytes: configuredImageCacheLimit(),
+ }, nil
+ }
+
+@@ -628,6 +628,7 @@ func (d *Driver) EnsureSandbox(ctx context.Context, input *fastletapi.EnsureSand
+ "sandboxID", identity.SandboxUID, "jailRoot", jailRoot)
+ }()
+ d.touchImage(restoreRef)
++ markImageBooted(stateRoot, restoreRef)
+ rootfsDur := time.Since(rootfsStarted) + ociPrepareDur
+ rootfsMiB := float64(0)
+ if info, statErr := os.Stat(instanceRootfs); statErr == nil {
+diff --git a/internal/runtime/firecracker/images.go b/internal/runtime/firecracker/images.go
+index d96d5ab..f28392b 100644
+--- a/internal/runtime/firecracker/images.go
++++ b/internal/runtime/firecracker/images.go
+@@ -11,7 +11,9 @@ import (
+ "os/exec"
+ "path/filepath"
+ "sort"
++ "strconv"
+ "strings"
++ "time"
+
+ "k8s.io/klog/v2"
+
+@@ -185,15 +187,71 @@ func copyFile(source, target string) error {
+ }
+
+ // defaultImageCacheLimitBytes bounds the converted-image cache. When the
+-// cache exceeds it, the GC evicts unreferenced images in least-frequently-used
+-// order. 20 GiB holds roughly a few dozen rootfs images.
++// cache exceeds it, the GC evicts unreferenced images (see evictionRank).
++// 20 GiB holds roughly a few dozen rootfs images, but only two 9 GiB desktops.
+ const defaultImageCacheLimitBytes = 20 << 30
+
++// imageCacheLimitEnv sizes the cache in GiB, for nodes whose images are large
++// or that keep the checkpoints of paused Sandboxes. It counts rootfs bytes
++// only, and a checkpoint's memory snapshot sits beside its rootfs.
++const imageCacheLimitEnv = "FAST_SANDBOX_IMAGE_CACHE_LIMIT_GIB"
++
++// configuredImageCacheLimit returns the limit imageCacheLimitEnv sets, or the
++// default when it is unset or invalid.
++func configuredImageCacheLimit() int64 {
++ value := os.Getenv(imageCacheLimitEnv)
++ if value == "" {
++ return defaultImageCacheLimitBytes
++ }
++ gib, err := strconv.ParseInt(value, 10, 32)
++ if err != nil || gib <= 0 {
++ klog.InfoS("ignoring invalid image cache limit", "env", imageCacheLimitEnv, "value", value)
++ return defaultImageCacheLimitBytes
++ }
++ return gib << 30
++}
++
++// Cache entries are shared by every Fastlet on a node, while the use counts
++// live in one Fastlet's memory, so a boot also leaves a marker in its entry
++// that all of them can read.
++const (
++ // consumedMarkerName marks a checkpoint a Sandbox has resumed from. That
++ // Sandbox's next pause writes a new checkpoint, so this one is spent.
++ consumedMarkerName = ".consumed"
++ // templateMarkerName marks an image Sandboxes are created from.
++ templateMarkerName = ".template"
++)
++
++// markImageBooted records what a boot says about its cache entry.
++func markImageBooted(stateRoot, image string) {
++ marker := templateMarkerName
++ if strings.HasPrefix(image, artifacts.CheckpointReference("")) {
++ marker = consumedMarkerName
++ }
++ path := filepath.Join(stateRoot, imageCacheDir, imageKey(image), marker)
++ if err := os.WriteFile(path, nil, 0o640); err != nil {
++ klog.V(2).InfoS("could not mark cached image", "image", image, "err", err)
++ }
++}
++
++// evictionRank orders unreferenced entries, lowest first: spent checkpoints,
++// then images no boot has marked (above all the checkpoint a paused Sandbox
++// will resume from, which no Sandbox state references), then templates.
++func evictionRank(entryDir string) int {
++ if _, err := os.Stat(filepath.Join(entryDir, consumedMarkerName)); err == nil {
++ return 0
++ }
++ if _, err := os.Stat(filepath.Join(entryDir, templateMarkerName)); err == nil {
++ return 2
++ }
++ return 1
++}
++
+ // garbageCollectImages evicts cached rootfs images to keep the cache under
+-// limitBytes. Candidates are images no managed Sandbox references; among them
+-// the least frequently used (recorded in memory by the driver, not derived
+-// from filesystem timestamps) are removed first, newest/unknown entries
+-// counting as zero uses. References are read from the durable per-Sandbox
++// limitBytes. Candidates are images no managed Sandbox references, by
++// evictionRank and then oldest first; the use counts recorded in memory by the
++// driver order templates only, because another Fastlet's image reads as unused
++// here however much it is needed. References are read from the durable per-Sandbox
+ // state, so a VM that died with its Fastlet still pins its image until its
+ // state directory is removed. Reflink instance copies are independent once
+ // created, so deleting a cache entry never affects a running VM. A Sandbox
+@@ -225,9 +283,11 @@ func garbageCollectImages(stateRoot string, limitBytes int64, useCount map[strin
+ return nil, err
+ }
+ type entry struct {
+- digest string
+- bytes int64
+- uses int64
++ digest string
++ bytes int64
++ uses int64
++ rank int
++ modified time.Time
+ }
+ entries := make([]entry, 0, len(cached))
+ total := int64(0)
+@@ -237,7 +297,8 @@ func garbageCollectImages(stateRoot string, limitBytes int64, useCount map[strin
+ if !validImageDigest(digest) {
+ continue
+ }
+- info, err := os.Stat(filepath.Join(stateRoot, imageCacheDir, digest, rootfsImageName))
++ entryDir := filepath.Join(stateRoot, imageCacheDir, digest)
++ info, err := os.Stat(filepath.Join(entryDir, rootfsImageName))
+ if err != nil {
+ continue
+ }
+@@ -245,14 +306,24 @@ func garbageCollectImages(stateRoot string, limitBytes int64, useCount map[strin
+ if _, used := referenced[digest]; used {
+ continue
+ }
+- entries = append(entries, entry{digest: digest, bytes: info.Size(), uses: useCount[digest]})
++ directory, err := os.Stat(entryDir)
++ if err != nil {
++ continue
++ }
++ entries = append(entries, entry{digest: digest, bytes: info.Size(), uses: useCount[digest], rank: evictionRank(entryDir), modified: directory.ModTime()})
+ }
+- // Least-frequently-used first; ties resolve deterministically by digest.
+ sort.Slice(entries, func(i, j int) bool {
+- if entries[i].uses != entries[j].uses {
+- return entries[i].uses < entries[j].uses
++ a, b := entries[i], entries[j]
++ if a.rank != b.rank {
++ return a.rank < b.rank
++ }
++ if a.rank == 2 && a.uses != b.uses {
++ return a.uses < b.uses
++ }
++ if !a.modified.Equal(b.modified) {
++ return a.modified.Before(b.modified)
+ }
+- return entries[i].digest < entries[j].digest
++ return a.digest < b.digest
+ })
+ removed := make([]string, 0, len(entries))
+ for _, candidate := range entries {
+diff --git a/internal/runtime/firecracker/images_test.go b/internal/runtime/firecracker/images_test.go
+index 52d81fc..8eeeb08 100644
+--- a/internal/runtime/firecracker/images_test.go
++++ b/internal/runtime/firecracker/images_test.go
+@@ -12,6 +12,7 @@ import (
+ "github.com/stretchr/testify/require"
+
+ apiv1alpha2 "fast-sandbox/api/v1alpha2"
++ "fast-sandbox/internal/artifacts"
+ runtimecatalog "fast-sandbox/internal/catalog/runtime"
+ fastletapi "fast-sandbox/internal/protocol/fastlet"
+ )
+@@ -99,9 +100,12 @@ func TestGarbageCollectImages(t *testing.T) {
+ require.NoError(t, os.WriteFile(path, []byte("rootfs"), 0o640))
+ return imageKey(image)
+ }
+- // hot and cold are both unreferenced; the LFU order evicts cold first.
++ // hot and cold are both unreferenced templates; the LFU order evicts cold
++ // first.
+ hot := seedImage("example.com/hot:v1")
+ cold := seedImage("example.com/cold:v1")
++ markImageBooted(root, "example.com/hot:v1")
++ markImageBooted(root, "example.com/cold:v1")
+ useCount[hot] = 100
+ useCount[cold] = 1
+ // unknown has no recorded use: it counts as zero and is the first victim.
+@@ -138,6 +142,8 @@ func TestGarbageCollectImages(t *testing.T) {
+ cold = seedImage("example.com/cold:v1")
+ unknown = seedImage("example.com/unknown:v1")
+ hot = seedImage("example.com/hot:v1")
++ markImageBooted(root, "example.com/hot:v1")
++ markImageBooted(root, "example.com/cold:v1")
+ removed, err = garbageCollectImages(root, 12, useCount)
+ require.NoError(t, err)
+ require.ElementsMatch(t, []string{unknown, cold}, removed)
+@@ -164,6 +170,68 @@ func TestGarbageCollectImages(t *testing.T) {
+ require.NoError(t, err, "high-frequency image must survive eviction")
+ }
+
++// TestImageGCKeepsWhatAPausedSandboxResumesFrom covers the cache every Fastlet
++// on a node shares: the Fastlet collecting has no use count for checkpoints
++// another one wrote or booted, so only what is on disk can rank them.
++func TestImageGCKeepsWhatAPausedSandboxResumesFrom(t *testing.T) {
++ root := t.TempDir()
++ const image = "12345678"
++ base := time.Now().Add(-time.Hour)
++ seed := func(reference string, age int) string {
++ digest := imageKey(reference)
++ dir := filepath.Join(root, imageCacheDir, digest)
++ require.NoError(t, os.MkdirAll(dir, 0o750))
++ require.NoError(t, os.WriteFile(filepath.Join(dir, rootfsImageName), []byte(image), 0o640))
++ modified := base.Add(time.Duration(age) * time.Minute)
++ require.NoError(t, os.Chtimes(dir, modified, modified))
++ return digest
++ }
++ exists := func(digest string) bool {
++ _, err := os.Stat(filepath.Join(root, imageCacheDir, digest))
++ return err == nil
++ }
++
++ template := seed("tpl-desktop", 0)
++ markImageBooted(root, "tpl-desktop")
++ spent := seed(artifacts.CheckpointReference("spent"), 3)
++ markImageBooted(root, artifacts.CheckpointReference("spent"))
++ older := seed(artifacts.CheckpointReference("paused-yesterday"), 1)
++ newest := seed(artifacts.CheckpointReference("paused-just-now"), 2)
++
++ // Marking changed the entries' times: put the ages back.
++ for digest, age := range map[string]int{template: 0, spent: 3} {
++ modified := base.Add(time.Duration(age) * time.Minute)
++ require.NoError(t, os.Chtimes(filepath.Join(root, imageCacheDir, digest), modified, modified))
++ }
++
++ // The collecting Fastlet booted only the template and an old checkpoint.
++ useCount := map[string]int64{template: 1, older: 1}
++
++ // Room for all but one: the spent checkpoint goes, though it is the newest.
++ removed, err := garbageCollectImages(root, 24, useCount)
++ require.NoError(t, err)
++ require.Equal(t, []string{spent}, removed)
++
++ // Room for two: the template outlives a paused checkpoint, and the older
++ // checkpoint goes first although this Fastlet has counted a use of it.
++ removed, err = garbageCollectImages(root, 16, useCount)
++ require.NoError(t, err)
++ require.Equal(t, []string{older}, removed)
++ require.True(t, exists(newest), "the checkpoint a paused Sandbox needs must survive")
++ require.True(t, exists(template), "the template must survive checkpoints")
++}
++
++func TestConfiguredImageCacheLimit(t *testing.T) {
++ t.Setenv(imageCacheLimitEnv, "")
++ require.Equal(t, int64(defaultImageCacheLimitBytes), configuredImageCacheLimit())
++ t.Setenv(imageCacheLimitEnv, "90")
++ require.Equal(t, int64(90<<30), configuredImageCacheLimit())
++ for _, invalid := range []string{"0", "-1", "lots"} {
++ t.Setenv(imageCacheLimitEnv, invalid)
++ require.Equal(t, int64(defaultImageCacheLimitBytes), configuredImageCacheLimit())
++ }
++}
++
+ // TestImageGCLoopRunsIndependently verifies the cache GC is driven by its own
+ // periodic loop, not by Sandbox lifecycle events, and stops on Close.
+ func TestImageGCLoopRunsIndependently(t *testing.T) {
diff --git a/docs/research/fast-sandbox-sparse-pull.patch b/docs/research/fast-sandbox-sparse-pull.patch
new file mode 100644
index 0000000..fdfb023
--- /dev/null
+++ b/docs/research/fast-sandbox-sparse-pull.patch
@@ -0,0 +1,183 @@
+Fast Sandbox: write pulled artifacts sparse
+
+Base: opensandbox-group/fast-sandbox main at 3d804c0 (2026-10-29), the code
+OpenSandbox 1.2.0 ships for these files. Apply with `git apply`.
+
+The node agent writes every pulled artifact densely (io.Copy), so a cached
+template's root filesystem occupies its full configured size and its memory
+snapshot the VM's full RAM, though both are mostly zeros. Every sandbox
+reflinks that rootfs, every checkpoint stages a reflink of the sandbox's, and
+a restored VM maps the template's memory file: the checksum pass and the
+upload of each pause then read gigabytes of zeros from disk, and the memory
+dump faults untouched guest pages in from it. copySparse skips all-zero
+64 KiB runs (holes) and sets the final size with a truncate; content and
+digest are unchanged, and SHA256File already hashes holes without reading.
+See docs/research/fast-sandbox-pause-time.md in different-ai/differently.
+
+diff --git a/internal/runtime/firecracker/agent/cache.go b/internal/runtime/firecracker/agent/cache.go
+index adaa055..c42b342 100644
+--- a/internal/runtime/firecracker/agent/cache.go
++++ b/internal/runtime/firecracker/agent/cache.go
+@@ -13,6 +13,7 @@ package agent
+ // resolveRootfsImage and GC work on the pulled artifacts unchanged.
+
+ import (
++ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+@@ -157,7 +158,7 @@ func stageFile(ctx context.Context, c *Client, dir, storeKey string, file native
+ }
+ }()
+ digest := sha256.New()
+- written, err := io.Copy(tmp, io.TeeReader(body, digest))
++ written, err := copySparse(tmp, io.TeeReader(body, digest))
+ if err != nil {
+ return fmt.Errorf("download %s: %w", file.publish, err)
+ }
+@@ -184,6 +185,46 @@ func stageFile(ctx context.Context, c *Client, dir, storeKey string, file native
+ return nil
+ }
+
++// sparseChunk is the granularity at which copySparse leaves holes: coarse
++// enough that a mostly-written file keeps few extents (reflink clones and
++// SEEK_DATA walks stay cheap), fine enough to drop an ext4 image's free space
++// and a memory snapshot's untouched RAM.
++const sparseChunk = 64 << 10
++
++// copySparse copies src to dst like io.Copy, but seeks over every all-zero
++// sparseChunk instead of writing it, then sets the final size, so zero runs
++// become holes. The file reads back byte-identical. Published root
++// filesystems are sized to their configured disk and memory snapshots to
++// the VM's RAM, mostly zeros: written densely, every sandbox's reflinked
++// rootfs and every checkpoint staged from it is read in full from disk when
++// it is hashed and uploaded. SHA256File and the upload read holes without
++// touching the disk.
++func copySparse(dst *os.File, src io.Reader) (int64, error) {
++ buf := make([]byte, 1<<20)
++ zero := make([]byte, sparseChunk)
++ var written int64
++ for {
++ n, readErr := io.ReadFull(src, buf)
++ for off := 0; off < n; off += sparseChunk {
++ chunk := buf[off:min(off+sparseChunk, n)]
++ if !bytes.Equal(chunk, zero[:len(chunk)]) {
++ if _, err := dst.WriteAt(chunk, written); err != nil {
++ return written, err
++ }
++ }
++ written += int64(len(chunk))
++ }
++ if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
++ break
++ }
++ if readErr != nil {
++ return written, readErr
++ }
++ }
++ // Holes at the end are not written: the size comes from the truncate.
++ return written, dst.Truncate(written)
++}
++
+ // commitManifest writes the downloaded manifest as the pull commit point:
+ // its presence with matching files means the pull is complete. The write is
+ // atomic so a crash never leaves a half-written manifest.
+diff --git a/internal/runtime/firecracker/agent/cache_sparse_test.go b/internal/runtime/firecracker/agent/cache_sparse_test.go
+new file mode 100644
+index 0000000..ef26bcf
+--- /dev/null
++++ b/internal/runtime/firecracker/agent/cache_sparse_test.go
+@@ -0,0 +1,93 @@
++package agent
++
++import (
++ "bytes"
++ "context"
++ "os"
++ "path/filepath"
++ "syscall"
++ "testing"
++
++ "github.com/stretchr/testify/require"
++
++ "fast-sandbox/internal/artifacts"
++)
++
++// A pulled artifact keeps its bytes and digest but leaves its zero runs as
++// holes: the rootfs of a desktop template is mostly unused disk.
++func TestPullImageLeavesZeroRunsSparse(t *testing.T) {
++ store, client, _, _ := publishFixture(t)
++ data := bytes.Repeat([]byte{0xab}, 3*sparseChunk)
++ rootfs := make([]byte, 0, 64*sparseChunk+100)
++ rootfs = append(rootfs, data...)
++ rootfs = append(rootfs, make([]byte, 40*sparseChunk)...)
++ rootfs = append(rootfs, data[:sparseChunk/2]...) // a partly written chunk stays data
++ rootfs = append(rootfs, make([]byte, 20*sparseChunk+100)...)
++ artifacts := map[string][]byte{"rootfs.ext4": rootfs, "vmstate.snap": []byte("vmstate-bytes"), "memory.snap": make([]byte, 8*sparseChunk)}
++ manifestPayload := testManifestFor(t, artifacts)
++ buildDir := sha256Hex(manifestPayload)[:16]
++ for name, content := range artifacts {
++ store.objects[buildDir+"/"+name] = content
++ }
++ store.objects[buildDir+"/manifest.json"] = manifestPayload
++ root := t.TempDir()
++
++ require.NoError(t, (&Client{s3: client}).PullCheckpoint(context.Background(), root, "checkpoint-sparse",
++ "s3://"+testBucket+"/"+testPrefix+"/"+buildDir+"/manifest.json", sha256Hex(manifestPayload)))
++
++ dir := imageDir(root, "checkpoint-sparse")
++ for cacheName, publishName := range map[string]string{"rootfs.img": "rootfs.ext4", "memory.snap": "memory.snap"} {
++ path := filepath.Join(dir, cacheName)
++ content, err := os.ReadFile(path)
++ require.NoError(t, err)
++ require.Equal(t, artifacts[publishName], content, cacheName)
++ var st syscall.Stat_t
++ require.NoError(t, syscall.Stat(path, &st))
++ allocated := st.Blocks * 512
++ if allocated >= int64(len(content))/2 {
++ t.Skipf("%s: %d of %d bytes allocated; this filesystem does not keep holes", cacheName, allocated, len(content))
++ }
++ }
++ complete, err := cacheComplete(dir)
++ require.NoError(t, err)
++ require.True(t, complete, "the sparse files must still verify against the manifest")
++}
++
++// testManifestFor is testManifest with a fixed x86 CPU identity, so the test
++// does not depend on the host it runs on.
++func testManifestFor(t *testing.T, contents map[string][]byte) []byte {
++ t.Helper()
++ files := map[string]artifacts.ManifestFile{}
++ for name, content := range contents {
++ files[name] = artifacts.ManifestFile{SHA256: sha256Hex(content), SizeBytes: int64(len(content))}
++ }
++ payload, err := artifacts.MarshalVMManifest(artifacts.VMManifest{
++ SchemaVersion: artifacts.VMManifestVersion,
++ Runtime: "firecracker",
++ Machine: artifacts.VMMachine{VCPUCount: 1, MemoryMiB: 1, RootfsSizeMiB: 1, RootfsDriveID: "rootfs", RootfsDrivePath: "rootfs.img"},
++ Kernel: artifacts.VMKernel{Version: "6.1.177"},
++ Compatibility: artifacts.SnapshotCompatibility{CPUArch: "x86_64", FirecrackerVersion: "1.16.1", CPUTemplate: artifacts.CPUTemplateNone, Vendor: artifacts.VendorGenuineIntel, CPUFamily: 6, CPUModel: 85, CPUStepping: 7},
++ Lineage: &artifacts.VMLineage{Image: testImage},
++ Files: files,
++ })
++ require.NoError(t, err)
++ return payload
++}
++
++func TestCopySparseMatchesSource(t *testing.T) {
++ for _, size := range []int{0, 1, sparseChunk - 1, sparseChunk, sparseChunk + 1, 3<<20 + 17} {
++ src := make([]byte, size)
++ for i := 0; i < size; i += 7919 {
++ src[i] = byte(i)
++ }
++ out, err := os.CreateTemp(t.TempDir(), "sparse")
++ require.NoError(t, err)
++ written, err := copySparse(out, bytes.NewReader(src))
++ require.NoError(t, err)
++ require.Equal(t, int64(size), written)
++ require.NoError(t, out.Close())
++ got, err := os.ReadFile(out.Name())
++ require.NoError(t, err)
++ require.Equal(t, src, got, "size %d", size)
++ }
++}
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 044048e..e0a2c7c 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -2,8 +2,8 @@
| | |
|---|---|
-| Status date | 2026-10-28 |
-| Evidence rule | **Done** means implemented and covered by automated tests, most of which run in-process with fakes. **Validated** means run against real infrastructure or live accounts; today only the `opensandbox` adapter with OpenSandbox's Docker runtime is validated. |
+| Status date | 2026-10-29 |
+| Evidence rule | **Done** means implemented and covered by automated tests, most of which run in-process with fakes. **Validated** means run against real infrastructure or live accounts: so far the `opensandbox` adapter on OpenSandbox's Docker, Kubernetes and Fast Sandbox runtimes, `agent-sandbox` on kind, and Daytona and Freestyle on live accounts. The default build holds only the validated `opensandbox`, `daytona` and `freestyle` adapters (plus `remote` and `devhost`); the other 13 are experimental and need the `experimental_adapters` build tag ([provider catalog](providers/README.md#what-the-server-contains)). |
| Detail | Tasks and acceptance criteria in [backlog.md](backlog.md); experiments in [proof-of-concepts.md](proof-of-concepts.md) |
| Runtime change | Differently's own Firecracker node agent was removed; OpenSandbox is the default private runtime. |
@@ -11,9 +11,9 @@ Legend: ✅ done (tested in-process) · ✅ live: also run against a real backen
```mermaid
flowchart LR
- P0["Phase 0 Foundations ✅"] --> P1["Phase 1 Private runtime 🟡 OpenSandbox (Docker runtime live)"]
- P0 --> P2["Phase 2 Hybrid placement and external providers 🟡 implemented, not validated"]
- P1 --> P3["Phase 3 Solidification 📋 next: Fast Sandbox spike"]
+ P0["Phase 0 Foundations ✅"] --> P1["Phase 1 Private runtime ✅ OpenSandbox, three runtimes live"]
+ P0 --> P2["Phase 2 Hybrid placement and external providers 🟡 Daytona and Freestyle live"]
+ P1 --> P3["Phase 3 Solidification 🟡 in progress"]
P2 --> P3
P3 --> P4["Phase 4 Scale and ecosystem 📋"]
```
@@ -58,17 +58,17 @@ with its own tooling and registered as a pool with `trust: private`.
| Item | Status | Where / notes |
|---|---|---|
-| `opensandbox` adapter | ✅ live (Docker runtime) | `internal/adapters/opensandbox`; fake-server tests plus `TestLiveOpenSandbox` against OpenSandbox server `release-1.2.0` and `execd` v1.1.0 (arm64, Colima); a manual run through Differently ([spike](research/opensandbox-spike.md)) |
+| `opensandbox` adapter | ✅ live (Docker, Kubernetes, Fast Sandbox) | `internal/adapters/opensandbox`; fake-server tests plus `TestLiveOpenSandbox` against OpenSandbox `release-1.2.0` on each runtime, recorded and replayed in every `go test` ([spike](research/opensandbox-spike.md)) |
| OpenSandbox as the default private runtime | ✅ decided | The Helm chart's `opensandbox:` values render a pool named `opensandbox` |
| OpenSandbox Kubernetes runtime | ✅ live (single node) | Single-node k3s on Azure: e2e 14/14 with a snapshot registry (stop/start, snapshots, fork), signed port URLs and egress policy, and recorded adapter runs; multi-node and drain not run ([POC 12](proof-of-concepts.md#poc-12-opensandbox-kubernetes-runtime)) |
-| OpenSandbox Fast Sandbox (Firecracker microVMs) | ✅ live (single node) | Single-node k3s on Azure under nested KVM: the recorded adapter scenario and Workbot's desktop (streamed, paused and resumed with memory, idled and woken); pause takes minutes; e2e suite not run yet ([E18](backlog.md#e18-fast-sandbox-spike)) |
-| End-to-end suite against a real OpenSandbox server | ✅ | `test/e2e` runs Postgres, `differently-server` and OpenSandbox 1.2.0 on the Docker runtime (14/14, arm64 Colima) and the Kubernetes runtime (14/14, k3s on Azure, Helm install), 2026-10-29 ([results](../test/e2e/README.md)). Fast Sandbox not yet |
+| OpenSandbox Fast Sandbox (Firecracker microVMs) | ✅ live (one KVM node) | First on a single node under nested KVM, then on a two-node k3s cluster on Azure with a dedicated KVM node: the recorded adapter scenario, the end-to-end suite on the microVM pool, and Workbot's desktop (streamed, paused and resumed with memory, forked, idled and woken); a pause takes minutes ([azure-vms.md](deploy/azure-vms.md), [E18](backlog.md#e18-fast-sandbox-spike)) |
+| End-to-end suite against a real OpenSandbox server | ✅ | `test/e2e` runs Postgres, `differently-server` and OpenSandbox 1.2.0 on the Docker runtime (14/14, arm64 Colima) and the Kubernetes runtime (14/14, k3s on Azure, Helm install), and on a Fast Sandbox microVM pool (14 of 14 run), 2026-10-29 ([results](../test/e2e/README.md)) |
| Sandbox egress control | 🟡 | Per pool: `egress: none` or `allowlist`, enforced by OpenSandbox's egress sidecar (checked live on the Kubernetes runtime); placement and per-request allow-lists not yet ([E19](backlog.md#e19-opensandbox-network-policy-and-egress)) |
| Node agent: lifecycle, capacity, heartbeat, `doctor` | ⛔ | Removed (`internal/agent`, `cmd/differently-agent`) |
| Firecracker API client | ⛔ | Removed (`internal/firecracker`) |
| Guest agent over vsock | ⛔ | Removed (`internal/guest`, `cmd/differently-guest`); the command runner moved to `internal/localexec` for `devhost` |
| Per-VM tap, /30, iptables isolation | ⛔ | Removed; isolation and egress are the runtime's |
-| Stop/start, disk snapshots, fork pinned to node | ⛔ | OpenSandbox has pause/resume and snapshots, no stop/start |
+| Stop/start, disk snapshots, fork pinned to node | ⛔ | Each OpenSandbox runtime provides its own (stop/start on Kubernetes, pause/resume on Docker and Fast Sandbox) |
| Restart recovery ([POC 2](proof-of-concepts.md#poc-2-agent-restart-semantics-in-kubernetes)) | ⛔ | Backend outages are now [POC 13](proof-of-concepts.md#poc-13-opensandbox-outage-and-recovery) |
| Firecracker pool adapter (multi-agent placement, routing) | ⛔ | Removed (`internal/adapters/firecracker`) |
| Helm chart with agent DaemonSet | ⛔ | The chart now deploys the control plane only, with an egress rule to OpenSandbox on port 80 |
@@ -79,10 +79,10 @@ with its own tooling and registered as a pool with `trust: private`.
| Authenticated agent channel ([E3](backlog.md#e3-authenticated-agent-channel)) | ⛔ | No agent channel; pools hold a backend credential by reference |
| Inbound ports on Firecracker ([E6](backlog.md#e6-preview-urls-for-firecracker-pools)) | ⛔ | OpenSandbox pools expose ports when OpenSandbox serves them without request headers |
-**Exit criteria (revised).** The end-to-end suite passes against a real
-OpenSandbox server with the Docker runtime; E18 (Fast Sandbox) and POC 12
-(Kubernetes runtime) recorded. The original criteria (POC 1, 2 and 9 on
-`.metal`) are obsolete.
+**Exit criteria (revised), met 2026-10-29.** The end-to-end suite passes
+against a real OpenSandbox server with the Docker runtime; E18 (Fast
+Sandbox) and POC 12 (Kubernetes runtime) recorded. The original criteria
+(POC 1, 2 and 9 on `.metal`) are obsolete.
## Phase 2: Hybrid placement and external providers
@@ -99,10 +99,12 @@ an operator-visible record of what left private infrastructure.
| OpenSandbox as an external pool | ✅ | `trust: external` pools follow the usual approval rules; trust must be declared |
| HTTP plugin protocol and `remote` pools | 🟡 | `pkg/adapter/adapterhttp`; untested since the agent tests, its only coverage, were removed ([E0.34](backlog.md#e0-correctness-and-security-fixes-from-the-code-review)); no protocol-version check yet |
| Usage and cost estimates | ✅ | `GET /v1/usage`; estimates only, no invoice reconciliation |
-| **Live-account verification** | 🟡 | [POC 7](proof-of-concepts.md#poc-7-external-provider-live-account-verification), [E2](backlog.md#e2-external-adapters-on-live-accounts) |
+| **Live-account verification** | 🟡 | Daytona and Freestyle done; the other 12 provider adapters are tested against fakes of their published APIs and are outside the default build (`experimental_adapters` tag) ([POC 7](proof-of-concepts.md#poc-7-external-provider-live-account-verification), [E2](backlog.md#e2-external-adapters-on-live-accounts)) |
-**Exit criteria (not met).** POC 7 complete; Daytona and Freestyle rows in
-[feature-parity.md](feature-parity.md) moved from 🧪 to ✅.
+**Exit criteria.** POC 7 complete; Daytona and Freestyle rows in
+[feature-parity.md](feature-parity.md) moved from 🧪 to ✅. The second part
+is met (2026-10-29); Daytona's VM class (pause, memory snapshots) has not
+run on an account that offers it.
## Phase 3: Solidification (next)
@@ -111,16 +113,23 @@ security gaps, and add the features users hit first.
> **[E18 Fast Sandbox spike](backlog.md#e18-fast-sandbox-spike) ran on
> 2026-10-29** on one node under nested KVM, through the adapter and with
-> Workbot's desktop. Next: the end-to-end suite on Fast Sandbox and a
-> multi-node cluster.
+> Workbot's desktop; on 2026-10-29 the end-to-end suite passed on a
+> two-node cluster ([azure-vms.md](deploy/azure-vms.md)). Next: node drains
+> and more than one KVM node.
+>
+> Also landed in this phase: paused sandboxes on checkpointing pools give
+> their slot back, with a resume queue, preemption and lifecycle limits
+> ([capacity](scheduling/capacity.md)); tenant keys see only the pools their
+> policies allow in `GET /v1/capabilities`; and a separate
+> [admin console](../console/README.md).
Ordered by priority. P0 items gate any production recommendation.
| Priority | Epic | Status |
|---|---|---|
-| P0 | **[E18 Fast Sandbox spike](backlog.md#e18-fast-sandbox-spike)** (POC 11) | ✅ single node, 2026-10-29 |
+| P0 | **[E18 Fast Sandbox spike](backlog.md#e18-fast-sandbox-spike)** (POC 11) | ✅ one KVM node, end-to-end suite included, 2026-10-29 |
| P0 | [E0 Correctness and security fixes from the code review](backlog.md#e0-correctness-and-security-fixes-from-the-code-review) | 📋 |
-| P0 | [E2 External adapters on live accounts](backlog.md#e2-external-adapters-on-live-accounts) (POC 7) | 📋 |
+| P0 | [E2 External adapters on live accounts](backlog.md#e2-external-adapters-on-live-accounts) (POC 7) | 🟡 Daytona and Freestyle live |
| P1 | [E19 OpenSandbox network policy and egress](backlog.md#e19-opensandbox-network-policy-and-egress) (replaces E12) | 📋 |
| P1 | [E21 OpenSandbox version pinning and upgrade testing](backlog.md#e21-opensandbox-version-pinning-and-upgrade-testing) (POC 13) | 📋 |
| P1 | [E5 Postgres in CI, failover and scale](backlog.md#e5-postgres-in-ci-failover-and-scale) (POC 6, 10) | 📋 |
diff --git a/docs/scheduling/capacity.md b/docs/scheduling/capacity.md
new file mode 100644
index 0000000..6a386c4
--- /dev/null
+++ b/docs/scheduling/capacity.md
@@ -0,0 +1,289 @@
+# Capacity: slots, paused sandboxes, preemption and lifecycle limits
+
+| | |
+|---|---|
+| Status | Describes the code as of 2026-11-02 |
+| Code | `internal/scheduler/capacity.go`, `internal/store/store.go` (`HoldsPoolSlot`, `ReclaimSlot`), `internal/capsim` (the model) |
+| Tests | `TestPausingSandboxRefusesWork`, `TestResumeCallsOffAPause`, `TestPreemptionSparesASandboxInUse`, `TestPausedSandboxGivesItsSlotBack`, `TestPreemptionMakesRoom`, `TestLifecycleLimits` (server), `TestVictimsOrder` (scheduler), `ReleasedSlotsAndReclaim` (store conformance, memory and Postgres), `TestStrategiesCompare` (model) |
+
+A pool's `maxConcurrency` is a number of **slots**. This page defines who
+holds them, how a slot moves from one sandbox to another, and what that buys,
+with numbers from a model of real usage.
+
+## 1. Who holds a slot
+
+| Status | Holds a slot |
+|---|---|
+| provisioning, running, pausing, stopped, terminating | yes |
+| paused, on a pool whose pause keeps the sandbox on the host (OpenSandbox Docker, Daytona) | yes |
+| paused, on a pool that **checkpoints** on pause (`lifecycle.pause-checkpoint`: OpenSandbox Fast Sandbox, Freestyle) | **no**: the pause wrote memory and disk out and left the host (`slotReleased: true`) |
+| queued, terminated, failed | no |
+
+Before this change every paused sandbox held its slot. On a checkpointing
+pool that is waste: a paused Fast Sandbox microVM uses no CPU or memory on the
+node, only artifact storage, yet four of them filled a four-slot pool for the
+14 days OpenWork keeps a computer.
+
+The slot is free when the pause **finishes**, not when it is asked for. A
+Fast Sandbox checkpoint of a 4 GiB desktop took a median 219 s with templates
+as Fast Sandbox 1.2.0 caches them and 182 s with sparse templates, on an
+Azure test host ([research](../research/fast-sandbox-pause-time.md)).
+
+### Pausing
+
+Between the two a sandbox is `pausing`. A Fast Sandbox VM freezes for its
+memory dump (25 to 50 s), then runs on while the checkpoint is hashed and
+uploaded, and is deleted when that is done. Whatever it does after the dump is
+gone when the checkpoint is restored, so exec, file and port calls on a pausing
+sandbox answer `invalid_state` (409). A connection already open to a port URL
+does not pass through Differently and stays up until the VM goes.
+
+A resume during a pause depends on the backend:
+
+- **It can call the pause off** (`adapter.PauseCanceller`: OpenSandbox Fast
+ Sandbox, whose runtime serves until it is released). The sandbox goes back to
+ running with everything it did, in about as long as the dump has left to run,
+ and the pause's caller gets a conflict.
+- **It cannot.** The resume joins the queue (202) and runs when the pause has
+ finished.
+
+Measured on a real Fast Sandbox with a 4 GiB desktop, each with a process and a
+file checked afterwards:
+
+| Step | Time |
+|---|---|
+| A pause, to `paused` | 281 s |
+| Resume after that pause | 1.1 s |
+| Resume during the checkpoint's upload | 1.0 s |
+| Resume during the memory dump | 41 s |
+
+**A called-off pause still costs its disk time.** The checkpoint already being
+written is not cancelled: it finishes hashing and uploading, then stays in the
+node's image cache and the artifact store with nothing referring to it. A new
+pause on that sandbox cannot start its dump until the old one reaches its
+upload, and then shares the disk with it. A pause started right after a
+called-off one took about 8 minutes, with the VM frozen for 151 s instead of 34.
+
+A failed pause goes back to running. If the replica waiting for a pause stops,
+the reconciler follows the backend: paused when it finishes, or back to running
+once no replica can still be waiting (15 minutes). Pauses that a backend starts
+by itself (native idling) are still seen only when they finish.
+
+## 2. Taking a slot back: resume and the resume queue
+
+Resuming a sandbox that released its slot takes one again, atomically with
+the pool's concurrency check (`ReclaimSlot`, under the same pool lock as
+activation):
+
+- **A free slot:** the resume proceeds as before (200).
+- **A full pool:** the resume is queued (`resumeRequestedAt`) and the API
+ answers **202** with the sandbox still paused and a reason (`waiting for a
+ free slot on pool …`). Clients wait with
+ `GET /v1/sandboxes/{id}/wait?status=running`.
+- **A call that wakes an idled sandbox** (exec, files, ports, keepalive)
+ queues the same way and waits up to 60 s; after that it answers
+ `insufficient_capacity` (503) and the resume stays queued.
+
+Queued resumes are served every tick: higher `priority` first, then oldest
+request. A queued resume cannot overflow to another pool: its memory is on the
+pool that took the checkpoint. Creates still overflow to approved external
+pools under `policy.external: overflow`, as before.
+
+A backend can wake a sandbox on its own (Fast Sandbox's gateway wakes a
+microVM on a request to its port URL, without asking Differently). The
+reconciler follows it back to running, which takes a slot; a full pool then
+shows as **overcommitted** (`differently_pool_overcommitted`) until a pause or
+preemption frees one.
+
+## 3. Making room: preemption
+
+A pool can let the scheduler pause running sandboxes when work waits for one
+of its slots: queued resumes pinned to it, and queued creates whose placement
+found it eligible but at its limit with no pool ready.
+
+```yaml
+pools:
+ - name: opensandbox-microvm
+ maxConcurrency: 4
+ preemption:
+ mode: idle # off (default) | idle | lru
+ idleSeconds: 60 # mode idle: no exec, file, port or keepalive call for this long
+ minRunSeconds: 600 # mode lru: never a sandbox that ran less than this
+```
+
+Also `PATCH /v1/admin/pools/{name}` with `{"preemption": {...}}`.
+
+| Mode | Candidates | Order |
+|---|---|---|
+| `off` | none | |
+| `idle` (recommended) | running, preemptible, no call in progress and idle for `idleSeconds` | least recently active, then lowest priority, then longest running |
+| `lru` | running, preemptible, ran for `minRunSeconds`, busy or not | same |
+
+Rules:
+
+- One victim per waiting sandbox, counting victims still pausing, so a
+ three-minute checkpoint is not answered with a second preemption.
+- Only pools that checkpoint on pause preempt: elsewhere a pause frees nothing.
+- A sandbox created with `"preemptible": false` (or patched so) is never a
+ victim.
+- The victim is paused with its memory and marked idled from the moment the
+ pause starts: its next call resumes it, through the queue if the pool is
+ still full, or calls the pause off if it has not finished. A pause called
+ off this way counts as a break for `maxRunSeconds`. Its reason says why
+ (`paused to make room on pool …`); the audit event is `sandbox.preempted`.
+- Activity is every exec, file, port and keepalive call, for every preemptible
+ sandbox whether or not it has an idle timeout. A command counts until it
+ ends. Work Differently cannot see, like a background job inside the guest or
+ someone using a port URL, needs a keepalive, or `preemptible: false`.
+
+## 4. Lifecycle limits
+
+Two limits complete the idle timeout. Each takes a request value, a
+`PATCH` value or the operator default (`defaults.maxRunSeconds`,
+`defaults.maxPausedSeconds`); `-1` turns it off; at least 60 when set.
+
+| Limit | Effect |
+|---|---|
+| `idleTimeoutSeconds` (existing) | pause (or stop) after this long without activity |
+| `maxRunSeconds` | pause (or stop) after running this long without a break, busy or not; the next call resumes it. A long runner gives its slot back periodically |
+| `maxPausedSeconds` | delete after staying paused or stopped this long, freeing its storage |
+| `ttlSeconds` (existing) | delete after this long, whatever its state |
+
+They run on the reconcile interval. Audit events: `sandbox.max_run`,
+`sandbox.expired` (with `reason: maxPausedSeconds`).
+
+## 5. What it buys: the model
+
+`internal/capsim` simulates one pool under four strategies with
+OpenWork-Workbot-like users:
+- each user owns one long-lived computer, created at their first session;
+- about six sessions a day, between 09:00 and 17:00;
+- sessions are log-normal, median 12 min;
+- computers pause after 5 min idle;
+- a pause takes 182 s (measured, see above) and a resume 2 s;
+- creates that find no slot overflow to an external pool.
+
+| Strategy | Meaning |
+|---|---|
+| `hold` | paused computers keep their slot (before this change) |
+| `release` | paused computers release their slot; resumes queue |
+| `preempt-idle` | release, plus preemption mode `idle` (60 s) |
+| `preempt-lru` | release, plus preemption mode `lru` (600 s) |
+
+Four slots, five simulated days, the average of five seeds
+(`go run ./cmd/differently-capsim -slots 4 -users 6,8,12,16,24`, pause 182 s):
+
+| users | strategy | users on the pool | external computer-hours | slot utilization | wait p50 | wait p95 | preemptions/day | interrupted sessions/day |
+|---:|---|---:|---:|---:|---:|---:|---:|---:|
+| 6 | hold | 67% | 17 | 6% | 2 s | 4 s | 0.0 | 0.0 |
+| 6 | release | 100% | 0 | 8% | 2 s | 13 s | 0.0 | 0.0 |
+| 6 | preempt-idle | 100% | 0 | 8% | 2 s | 13 s | 0.2 | 0.0 |
+| 6 | preempt-lru | 100% | 0 | 8% | 2 s | 33 s | 0.2 | 0.2 |
+| 8 | hold | 50% | 36 | 6% | 2 s | 2 s | 0.0 | 0.0 |
+| 8 | release | 95% | 4 | 10% | 2 s | 56 s | 0.0 | 0.0 |
+| 8 | preempt-idle | 95% | 4 | 10% | 2 s | 56 s | 0.8 | 0.0 |
+| 8 | preempt-lru | 95% | 4 | 10% | 2 s | 120 s | 1.1 | 0.8 |
+| 12 | hold | 33% | 76 | 6% | 2 s | 2 s | 0.0 | 0.0 |
+| 12 | release | 87% | 15 | 14% | 2 s | 439 s | 0.0 | 0.0 |
+| 12 | preempt-idle | 87% | 15 | 14% | 2 s | 250 s | 6.6 | 0.0 |
+| 12 | preempt-lru | 87% | 15 | 14% | 2 s | 344 s | 10.2 | 7.6 |
+| 16 | hold | 25% | 120 | 6% | 2 s | 2 s | 0.0 | 0.0 |
+| 16 | release | 74% | 42 | 15% | 2 s | 571 s | 0.0 | 0.0 |
+| 16 | preempt-idle | 74% | 42 | 15% | 2 s | 374 s | 12.0 | 0.0 |
+| 16 | preempt-lru | 74% | 42 | 15% | 4 s | 476 s | 16.9 | 12.8 |
+| 24 | hold | 17% | 195 | 6% | 2 s | 2 s | 0.0 | 0.0 |
+| 24 | release | 58% | 97 | 17% | 2 s | 650 s | 0.0 | 0.0 |
+| 24 | preempt-idle | 58% | 97 | 17% | 2 s | 417 s | 19.7 | 0.0 |
+| 24 | preempt-lru | 58% | 97 | 17% | 2 s | 483 s | 26.5 | 19.9 |
+
+"Wait" is from a session's start until its computer runs, over every session.
+Utilization counts slot time used by a session in progress.
+
+What it shows:
+
+- **Releasing paused slots multiplies what a pool serves.** At 12 users the
+ same four slots keep 87% of computers private instead of 33%, and overflow
+ falls from 76 to 15 computer-hours. Utilization still stays low, because
+ people work in bursts; more of the slots' idle time now goes to someone.
+- **The price is a queue.** Once the pool is oversubscribed, a returning user
+ can wait for a slot, and slots free only after a checkpoint (about three
+ minutes). Holding never queues, because it overflows instead, and pays for
+ it in external hours.
+- **Idle preemption shortens that queue by about 35–45% and never interrupts
+ work.** It reclaims slots from computers idle for a minute, four minutes
+ before their own idle pause would.
+- **LRU preemption is a poor default.** It interrupts 8–20 sessions a day at
+ 12–24 users for a smaller gain. It is there for pools where `priority` must
+ win.
+
+How pause time moves the wait (`-pause`; 216 s and 182 s measured before and
+after sparse templates, 90 s and 30 s what the upstream changes in the
+[research](../research/fast-sandbox-pause-time.md#remaining-ideas-that-need-an-upstream-change)
+could reach):
+
+| users | pause | release: wait p95 | preempt-idle: wait p95 | external computer-hours |
+|---:|---:|---:|---:|---:|
+| 8 | 216 s | 88 s | 89 s | 4 |
+| 8 | 182 s | 56 s | 56 s | 4 |
+| 8 | 90 s | 9 s | 9 s | 3 |
+| 8 | 30 s | 2 s | 2 s | 3 |
+| 12 | 216 s | 459 s | 277 s | 17 |
+| 12 | 182 s | 439 s | 250 s | 15 |
+| 12 | 90 s | 361 s | 158 s | 12 |
+| 12 | 30 s | 329 s | 130 s | 10 |
+| 16 | 216 s | 564 s | 396 s | 46 |
+| 16 | 182 s | 571 s | 374 s | 42 |
+| 16 | 90 s | 586 s | 375 s | 32 |
+| 16 | 30 s | 555 s | 339 s | 27 |
+
+- **A faster pause empties a light queue.** At 8 users the p95 wait falls
+ from 88 s to 56 s with sparse templates, and to seconds below 90 s.
+- **On a busy pool idle preemption matters more.** At 12 users most of the
+ wait is slots held by running computers that are not yet idle: halving the
+ pause takes `release` from 439 s to 361 s, while preemption takes it to
+ 250 s, and to 158 s with the shorter pause. Faster pauses also keep more
+ users private (external hours 17 → 12 at 12 users, 46 → 32 at 16).
+
+Choosing:
+
+- Size the slots so the p95 wait you accept holds at your user count.
+- Use `release` (automatic on checkpointing pools) with preemption `idle`.
+- Keep an external overflow pool for creates, so new users never queue.
+- Watch `differently_resume_wait_seconds`, `differently_pause_seconds` and
+ `differently_pool_slots` in production to check the model against reality.
+
+At six users on four slots, OpenWork's case on Azure today, everything stays
+private with a 13 s p95.
+
+Limits of the model:
+- one computer per user;
+- no time zones;
+- pause and resume times are constants (measured on Azure, see
+ [opensandbox-pools.md](../guides/opensandbox-pools.md#measured-on-a-workbot-desktop));
+ in reality pauses on one node share its disk, so two at once each take
+ longer, and a burstable disk that runs out of credits slows every pause
+ ([research](../research/fast-sandbox-pause-time.md));
+- external pools are unbounded;
+- the scheduler's real tick and the backend-woken case are not modeled.
+
+## 6. Metrics
+
+| Metric | Labels | Meaning |
+|---|---|---|
+| `differently_pool_slots` | pool, state = held, released, waiting_resume, limit | slot use per pool, every tick |
+| `differently_pool_overcommitted` | pool | slots held beyond `maxConcurrency` (backend wakes) |
+| `differently_resumes_queued_total` | pool | resumes that had to wait |
+| `differently_resume_wait_seconds` | pool | how long they waited |
+| `differently_pause_seconds` | pool | how long pauses Differently asked for took on the backend, counting only those that finished, not failed or called-off ones: on a checkpointing pool, the time until the slot is free (capsim's `PauseTime`). A backend's own idle pause (Fast Sandbox native idle) is not timed |
+| `differently_preemptions_total` | pool, mode | sandboxes paused to make room |
+| `differently_lifecycle_actions_total` | action = preempted, max_run, max_paused; pool | the scheduler's own pauses and deletions |
+
+## 7. Compatibility
+
+- Existing sandboxes are not preemptible (the field defaults to true only for
+ new ones), and nothing preempts unless a pool sets a policy.
+- A client that resumed a sandbox and expected 200 can now get 202 on a full
+ checkpointing pool. OpenWork's adapter already waits for `running` after a
+ resume.
+- Tenant quotas are unchanged: a paused sandbox still counts toward
+ `maxActiveSandboxes`.
diff --git a/docs/scheduling/placement-policy.md b/docs/scheduling/placement-policy.md
index d7667e3..2727624 100644
--- a/docs/scheduling/placement-policy.md
+++ b/docs/scheduling/placement-policy.md
@@ -111,7 +111,7 @@ An eligible pool **has capacity** unless (first match wins):
| Check | Reason |
|---|---|
| Health probe failed, adapter could not be built, or the pool was marked unhealthy after a provisioning failure | `unhealthy: ` |
-| `maxConcurrency > 0` and active slots ≥ cap (slots: provisioning, running, paused, stopped, terminating) | `at concurrency limit N` |
+| `maxConcurrency > 0` and active slots ≥ cap (slots: provisioning, running, stopped, terminating, and paused unless the pool checkpoints on pause; see [capacity.md](capacity.md)) | `at concurrency limit N` |
| Bounded capacity and the sandbox does not fit (uses the roomiest single node when reported) | `insufficient free capacity` |
Unbounded adapters (OpenSandbox, Daytona, Freestyle, devhost) always fit;
diff --git a/docs/storage-and-lifecycle.md b/docs/storage-and-lifecycle.md
index 9714a26..aeca7d9 100644
--- a/docs/storage-and-lifecycle.md
+++ b/docs/storage-and-lifecycle.md
@@ -85,7 +85,8 @@ terminated like running ones.
sandbox is `Paused`; `resume` waits for `Running` and then for `execd` to
answer again. A paused sandbox keeps reserving CPU and memory in the tenant's
quota and its pool concurrency slot. Exec and file operations on a paused
-sandbox return `409 invalid_state`.
+or `pausing` sandbox return `409 invalid_state`
+([what a pause in progress means](scheduling/capacity.md#pausing)).
Whether process memory survives a pause depends on the runtime. The live test
checks that files survive pause and resume on the Docker runtime; it does not
@@ -179,7 +180,7 @@ sequenceDiagram
| Snapshots between adapters | Refs are adapter-specific (OpenSandbox snapshot ID, Daytona snapshot name, Freestyle snapshot ID) | Placement pins restores to the snapshot's pool |
| Memory state between runtimes, versions or CPU types | Each runtime has its own snapshot format and compatibility rules | Restore on matching hosts |
| Network identity | Each sandbox gets its address from the runtime | Applications must not depend on the sandbox IP |
-| Provider-side lifetimes | OpenSandbox `timeout`, Daytona `ttlMinutes` and Freestyle `ttlSeconds` are set once at create | Extending a sandbox's TTL with `PATCH` does not update the backstop; the backend may delete it earlier and the reconciler then marks it failed (known issue) |
+| Provider-side lifetimes | OpenSandbox `timeout`, Daytona `ttlMinutes` and Freestyle `ttlSeconds` are set once at create, to the TTL plus five minutes so that Differently's own expiry runs first | Extending a sandbox's TTL with `PATCH` does not update the backstop; the backend may delete it earlier and the reconciler then marks it failed (known issue) |
## 9. Memory snapshot plan
diff --git a/go.mod b/go.mod
index d51765c..e8118ae 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.26.0
require (
github.com/jackc/pgx/v5 v5.11.0
github.com/prometheus/client_golang v1.25.0
+ github.com/prometheus/client_model v0.6.3
github.com/spf13/cobra v1.10.2
sigs.k8s.io/yaml v1.6.0
)
@@ -17,7 +18,6 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
diff --git a/internal/adapters/agentcore/agentcore.go b/internal/adapters/agentcore/agentcore.go
index 8a625d8..49fb693 100644
--- a/internal/adapters/agentcore/agentcore.go
+++ b/internal/adapters/agentcore/agentcore.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package agentcore adapts Amazon Bedrock AgentCore Code Interpreter: each
// Differently sandbox is one code-interpreter session.
//
diff --git a/internal/adapters/agentcore/agentcore_test.go b/internal/adapters/agentcore/agentcore_test.go
index 08fe144..89cb30b 100644
--- a/internal/adapters/agentcore/agentcore_test.go
+++ b/internal/adapters/agentcore/agentcore_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package agentcore
import (
diff --git a/internal/adapters/agentsandbox/agentsandbox.go b/internal/adapters/agentsandbox/agentsandbox.go
index 03c19d9..15eaec5 100644
--- a/internal/adapters/agentsandbox/agentsandbox.go
+++ b/internal/adapters/agentsandbox/agentsandbox.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package agentsandbox adapts kubernetes-sigs/agent-sandbox
// (github.com/kubernetes-sigs/agent-sandbox, Apache-2.0): a Sandbox custom
// resource that a controller turns into one stateful pod, with optional warm
diff --git a/internal/adapters/agentsandbox/agentsandbox_test.go b/internal/adapters/agentsandbox/agentsandbox_test.go
index ca1e3ca..cecf2f8 100644
--- a/internal/adapters/agentsandbox/agentsandbox_test.go
+++ b/internal/adapters/agentsandbox/agentsandbox_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package agentsandbox
import (
diff --git a/internal/adapters/agentsandbox/kube.go b/internal/adapters/agentsandbox/kube.go
index 8a6e2cc..4b41a7c 100644
--- a/internal/adapters/agentsandbox/kube.go
+++ b/internal/adapters/agentsandbox/kube.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package agentsandbox
// A small Kubernetes API client on the standard library: JSON requests with
diff --git a/internal/adapters/agentsandbox/kube_test.go b/internal/adapters/agentsandbox/kube_test.go
index f786f46..7ba6bfe 100644
--- a/internal/adapters/agentsandbox/kube_test.go
+++ b/internal/adapters/agentsandbox/kube_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package agentsandbox
import (
diff --git a/internal/adapters/agentsandbox/live_test.go b/internal/adapters/agentsandbox/live_test.go
index 986fbae..0232b53 100644
--- a/internal/adapters/agentsandbox/live_test.go
+++ b/internal/adapters/agentsandbox/live_test.go
@@ -1,4 +1,4 @@
-//go:build live
+//go:build live && experimental_adapters
package agentsandbox
@@ -7,7 +7,7 @@ package agentsandbox
//
// AGENTSANDBOX_API_SERVER=https://127.0.0.1:6443 AGENTSANDBOX_CA_FILE=ca.crt \
// AGENTSANDBOX_TOKEN=$(kubectl -n dly create token differently) \
-// go test -tags live -v -run TestLiveAgentSandbox ./internal/adapters/agentsandbox/
+// go test -tags live,experimental_adapters -v -run TestLiveAgentSandbox ./internal/adapters/agentsandbox/
//
// Settings: AGENTSANDBOX_NAMESPACE (default dly), AGENTSANDBOX_IMAGE (default
// debian:bookworm-slim), AGENTSANDBOX_VOLUME_SIZE (enables stop/start, default
diff --git a/internal/adapters/azuresandboxes/azuresandboxes.go b/internal/adapters/azuresandboxes/azuresandboxes.go
index 1942463..661c5a4 100644
--- a/internal/adapters/azuresandboxes/azuresandboxes.go
+++ b/internal/adapters/azuresandboxes/azuresandboxes.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package azuresandboxes adapts Azure Container Apps Sandboxes: each
// Differently sandbox is one sandbox in an Azure sandbox group.
//
diff --git a/internal/adapters/azuresandboxes/azuresandboxes_test.go b/internal/adapters/azuresandboxes/azuresandboxes_test.go
index 1d18747..aa908f0 100644
--- a/internal/adapters/azuresandboxes/azuresandboxes_test.go
+++ b/internal/adapters/azuresandboxes/azuresandboxes_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package azuresandboxes
import (
diff --git a/internal/adapters/azuresessions/azuresessions.go b/internal/adapters/azuresessions/azuresessions.go
index d1ee573..973e31b 100644
--- a/internal/adapters/azuresessions/azuresessions.go
+++ b/internal/adapters/azuresessions/azuresessions.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package azuresessions adapts Azure Container Apps dynamic sessions in a
// Python code-interpreter session pool: each Differently sandbox is one
// session, named by its identifier.
diff --git a/internal/adapters/azuresessions/azuresessions_test.go b/internal/adapters/azuresessions/azuresessions_test.go
index 62bd589..42a9666 100644
--- a/internal/adapters/azuresessions/azuresessions_test.go
+++ b/internal/adapters/azuresessions/azuresessions_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package azuresessions
import (
diff --git a/internal/adapters/blaxel/blaxel.go b/internal/adapters/blaxel/blaxel.go
index 86c259d..7333236 100644
--- a/internal/adapters/blaxel/blaxel.go
+++ b/internal/adapters/blaxel/blaxel.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package blaxel adapts Blaxel's hosted sandbox API.
//
// Blaxel is hosted-only, so pools on this adapter are always TrustExternal.
diff --git a/internal/adapters/blaxel/blaxel_test.go b/internal/adapters/blaxel/blaxel_test.go
index 0ca6d0f..f6155cd 100644
--- a/internal/adapters/blaxel/blaxel_test.go
+++ b/internal/adapters/blaxel/blaxel_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package blaxel
import (
diff --git a/internal/adapters/boat/boat.go b/internal/adapters/boat/boat.go
index 11d57a1..4daf185 100644
--- a/internal/adapters/boat/boat.go
+++ b/internal/adapters/boat/boat.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package boat adapts Boat's hosted sandbox API (/api/v1).
//
// Boat is hosted-only, so pools on this adapter are always TrustExternal.
diff --git a/internal/adapters/boat/boat_test.go b/internal/adapters/boat/boat_test.go
index 796633d..708d828 100644
--- a/internal/adapters/boat/boat_test.go
+++ b/internal/adapters/boat/boat_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package boat
import (
diff --git a/internal/adapters/cloudhttp/auth.go b/internal/adapters/cloudhttp/auth.go
index 5c21b9d..09f9079 100644
--- a/internal/adapters/cloudhttp/auth.go
+++ b/internal/adapters/cloudhttp/auth.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package cloudhttp
import (
diff --git a/internal/adapters/cloudhttp/client.go b/internal/adapters/cloudhttp/client.go
index b55e7d4..b132929 100644
--- a/internal/adapters/cloudhttp/client.go
+++ b/internal/adapters/cloudhttp/client.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package cloudhttp calls cloud providers' sandbox APIs with cloud IAM
// credentials: Microsoft Entra ID tokens, Google OAuth 2.0 tokens and AWS
// Signature Version 4. It uses only the standard library, so the cloud
diff --git a/internal/adapters/cloudhttp/cloudhttp_test.go b/internal/adapters/cloudhttp/cloudhttp_test.go
index 40228a5..6acd162 100644
--- a/internal/adapters/cloudhttp/cloudhttp_test.go
+++ b/internal/adapters/cloudhttp/cloudhttp_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package cloudhttp
import (
diff --git a/internal/adapters/cloudhttp/eventstream.go b/internal/adapters/cloudhttp/eventstream.go
index 2d9eb8b..baa71fc 100644
--- a/internal/adapters/cloudhttp/eventstream.go
+++ b/internal/adapters/cloudhttp/eventstream.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package cloudhttp
import (
diff --git a/internal/adapters/cloudhttp/sigv4.go b/internal/adapters/cloudhttp/sigv4.go
index 2ce4788..7fd2b01 100644
--- a/internal/adapters/cloudhttp/sigv4.go
+++ b/internal/adapters/cloudhttp/sigv4.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package cloudhttp
import (
diff --git a/internal/adapters/cubesandbox/cubesandbox.go b/internal/adapters/cubesandbox/cubesandbox.go
index a033e88..4967955 100644
--- a/internal/adapters/cubesandbox/cubesandbox.go
+++ b/internal/adapters/cubesandbox/cubesandbox.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package cubesandbox adapts CubeSandbox (github.com/TencentCloud/CubeSandbox,
// Apache-2.0): KVM microVMs (cube-hypervisor, a Cloud Hypervisor fork) behind
// an E2B-compatible API.
diff --git a/internal/adapters/cubesandbox/cubesandbox_test.go b/internal/adapters/cubesandbox/cubesandbox_test.go
index f2a692a..d67bddb 100644
--- a/internal/adapters/cubesandbox/cubesandbox_test.go
+++ b/internal/adapters/cubesandbox/cubesandbox_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package cubesandbox
import (
diff --git a/internal/adapters/cubesandbox/envd.go b/internal/adapters/cubesandbox/envd.go
index a63240a..547141c 100644
--- a/internal/adapters/cubesandbox/envd.go
+++ b/internal/adapters/cubesandbox/envd.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package cubesandbox
// envd is E2B's agent inside each sandbox (CubeSandbox builds it from
diff --git a/internal/adapters/cubesandbox/live_test.go b/internal/adapters/cubesandbox/live_test.go
index b399dfd..92e58f3 100644
--- a/internal/adapters/cubesandbox/live_test.go
+++ b/internal/adapters/cubesandbox/live_test.go
@@ -1,4 +1,4 @@
-//go:build live
+//go:build live && experimental_adapters
package cubesandbox
@@ -6,7 +6,7 @@ package cubesandbox
// docs/providers/cubesandbox.md):
//
// CUBE_API_URL=http://:3000 CUBE_TEMPLATE_ID= \
-// CUBE_SANDBOX_URL=http://:80 go test -tags live -v -run TestLiveCubeSandbox ./internal/adapters/cubesandbox/
+// CUBE_SANDBOX_URL=http://:80 go test -tags live,experimental_adapters -v -run TestLiveCubeSandbox ./internal/adapters/cubesandbox/
//
// Settings: CUBE_API_KEY (when CubeAPI has auth on), CUBE_SANDBOX_URL
// (CubeProxy, for header routing; without it, host routing through
diff --git a/internal/adapters/daytona/backstop_test.go b/internal/adapters/daytona/backstop_test.go
new file mode 100644
index 0000000..5be44d6
--- /dev/null
+++ b/internal/adapters/daytona/backstop_test.go
@@ -0,0 +1,25 @@
+package daytona
+
+import (
+ "context"
+ "testing"
+
+ "github.com/different-ai/differently/pkg/adapter"
+)
+
+// Daytona's deletion deadline counts from the create request and Differently's
+// TTL from the moment the sandbox is ready, so the deadline is sent with a
+// grace: Differently's own, audited expiry must run first.
+func TestDaytonaBackstopOutlastsTheTTL(t *testing.T) {
+ if got := ttlMinutes(600); got != 15 {
+ t.Fatalf("ttlMinutes %d for a 600 s TTL, want 15 (the TTL plus a 5-minute grace)", got)
+ }
+ if got := ttlMinutes(1); got != 6 {
+ t.Fatalf("ttlMinutes %d for a 1 s TTL, want 6 (rounded up, plus the grace)", got)
+ }
+ f := newFake(t)
+ a := New(Options{APIURL: f.srv.URL + "/api", APIKey: "k", Snapshot: "daytona-small"})
+ if _, err := a.Create(context.Background(), adapter.CreateRequest{ID: "sbx_ttl", Installation: "inst_1", TTLSeconds: 600}); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/adapters/daytona/daytona.go b/internal/adapters/daytona/daytona.go
index c387f19..c5841ba 100644
--- a/internal/adapters/daytona/daytona.go
+++ b/internal/adapters/daytona/daytona.go
@@ -34,6 +34,15 @@ import (
// DefaultAPIURL is Daytona's hosted control plane.
const DefaultAPIURL = "https://app.daytona.io/api"
+// backstopGrace keeps Daytona's own deletion behind Differently's audited
+// expiry: Daytona counts from the create request, Differently from the moment
+// the sandbox is ready.
+const backstopGrace = 5 * 60
+
+// ttlMinutes is the deletion deadline sent to Daytona for a sandbox TTL: the
+// TTL plus the grace, rounded up to whole minutes.
+func ttlMinutes(ttlSeconds int) int { return (ttlSeconds + backstopGrace + 59) / 60 }
+
// Options configures the adapter. Pool config keys: apiURL, target, snapshot,
// class (container|linux-vm). Daytona's own auto-stop is always off:
// Differently owns TTL and lifecycle.
@@ -170,9 +179,16 @@ func (a *Adapter) Create(ctx context.Context, req adapter.CreateRequest) (adapte
return adapter.Instance{}, err
}
var s sandbox
- if len(existing) > 0 {
- s = existing[0]
- } else {
+ // Reuse only a sandbox that carries this ID: the label filter is the
+ // provider's, and one it ignored must not hand over someone else's.
+ found := false
+ for _, e := range existing {
+ if e.Labels[adapter.LabelSandboxID] == req.ID {
+ s, found = e, true
+ break
+ }
+ }
+ if !found {
snapshot := a.opts.Snapshot
if req.Template != "" {
snapshot = req.Template
@@ -194,7 +210,7 @@ func (a *Adapter) Create(ctx context.Context, req adapter.CreateRequest) (adapte
}
if req.TTLSeconds > 0 {
// Backstop in case this control plane is lost: the provider deletes too.
- body["ttlMinutes"] = (req.TTLSeconds + 59) / 60
+ body["ttlMinutes"] = ttlMinutes(req.TTLSeconds)
}
if err := a.c.JSON(ctx, http.MethodPost, "/sandbox", body, &s); err != nil {
return adapter.Instance{}, err
@@ -233,6 +249,11 @@ func (a *Adapter) List(ctx context.Context, installation string) ([]adapter.Inst
}
var out []adapter.Instance
for _, s := range items {
+ // Never trust a filter we did not write: the reconciler deletes what
+ // List returns and the control plane does not know.
+ if s.Labels[adapter.LabelInstallation] != installation || s.Labels[adapter.LabelSandboxID] == "" {
+ continue
+ }
if s.State != "destroyed" && s.State != "destroying" {
out = append(out, a.instance(s))
}
diff --git a/internal/adapters/daytona/daytona_test.go b/internal/adapters/daytona/daytona_test.go
index d859c64..bda1f68 100644
--- a/internal/adapters/daytona/daytona_test.go
+++ b/internal/adapters/daytona/daytona_test.go
@@ -19,12 +19,15 @@ import (
// change is still in progress, a repeated delete during it is a 409, a
// command timeout is a 408, and snapshots are deleted by id only.
type fakeDaytona struct {
- mu sync.Mutex
- creates int
- boxes map[string]map[string]any
- files map[string][]byte
- snapshots map[string]map[string]any // by name
- srv *httptest.Server
+ mu sync.Mutex
+ creates int
+ // ignoreFilter makes the list return the whole account, as a provider
+ // that dropped or renamed the labels parameter would.
+ ignoreFilter bool
+ boxes map[string]map[string]any
+ files map[string][]byte
+ snapshots map[string]map[string]any // by name
+ srv *httptest.Server
}
// settle is what one GET later finds for a state in progress.
@@ -113,7 +116,7 @@ func newFake(t *testing.T) *fakeDaytona {
match = false
}
}
- if match {
+ if match || f.ignoreFilter {
items = append(items, b)
}
}
@@ -282,3 +285,31 @@ func TestDaytonaErrorMapping(t *testing.T) {
t.Fatalf("429 should requeue as insufficient capacity: %v", err)
}
}
+
+// The reconciler deletes what List returns and the control plane does not
+// know, so List and Create must check ownership themselves: a provider that
+// ignores the label filter returns every sandbox in the account.
+func TestDaytonaIgnoresSandboxesItDoesNotOwn(t *testing.T) {
+ f := newFake(t)
+ f.ignoreFilter = true
+ f.boxes["stranger"] = map[string]any{"id": "stranger", "state": "started", "labels": map[string]any{"team": "payments"}, "target": "us"}
+ f.boxes["other-install"] = map[string]any{"id": "other-install", "state": "started", "target": "us",
+ "labels": map[string]any{adapter.LabelInstallation: "inst_other", adapter.LabelSandboxID: "sbx_theirs"}}
+ a := New(Options{APIURL: f.srv.URL + "/api", APIKey: "k", Snapshot: "daytona-small"})
+ ctx := context.Background()
+
+ inst, err := a.Create(ctx, adapter.CreateRequest{ID: "sbx_1", Installation: "inst_1", Resources: api.Resources{CPU: 1, MemoryMiB: 1024}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.creates != 1 || inst.Ref == "stranger" || inst.Ref == "other-install" {
+ t.Fatalf("create adopted a sandbox it does not own: %+v (creates %d)", inst, f.creates)
+ }
+ list, err := a.List(ctx, "inst_1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 1 || list[0].SandboxID != "sbx_1" {
+ t.Fatalf("list returned sandboxes of other owners: %+v", list)
+ }
+}
diff --git a/internal/adapters/e2b/e2b.go b/internal/adapters/e2b/e2b.go
index 3289b40..eb40336 100644
--- a/internal/adapters/e2b/e2b.go
+++ b/internal/adapters/e2b/e2b.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package e2b adapts E2B's sandbox API: the hosted service, or an E2B the
// operator runs (its infrastructure is open source), so pools declare trust.
//
diff --git a/internal/adapters/e2b/e2b_test.go b/internal/adapters/e2b/e2b_test.go
index 8411a88..16d8781 100644
--- a/internal/adapters/e2b/e2b_test.go
+++ b/internal/adapters/e2b/e2b_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package e2b
import (
diff --git a/internal/adapters/fake/fake.go b/internal/adapters/fake/fake.go
index 390be54..9f03bb0 100644
--- a/internal/adapters/fake/fake.go
+++ b/internal/adapters/fake/fake.go
@@ -4,6 +4,7 @@ package fake
import (
"context"
+ "errors"
"fmt"
"sort"
"sync"
@@ -61,11 +62,24 @@ type Adapter struct {
// NativeIdle makes the fake claim its backend idles instances itself
// (adapter.NativeIdler); tests pause and resume instances to act for it.
NativeIdle bool
+ // HoldPauses makes Pause wait until it is closed, like a checkpoint that
+ // takes minutes; the instance keeps running meanwhile. FailPauses fails
+ // the next N.
+ HoldPauses chan struct{}
+ FailPauses int
+ // PauseCancels makes the fake claim a resume calls off a pause in
+ // progress (adapter.PauseCanceller).
+ PauseCancels bool
+ // HoldExecs makes Exec wait until it is closed, like a long command.
+ HoldExecs chan struct{}
}
// IdlesNatively implements adapter.NativeIdler.
func (a *Adapter) IdlesNatively() bool { return a.NativeIdle }
+// CancelsPause implements adapter.PauseCanceller.
+func (a *Adapter) CancelsPause() bool { return a.PauseCancels }
+
// RefOf returns the instance ref backing a sandbox.
func (a *Adapter) RefOf(sandboxID string) string {
a.mu.Lock()
@@ -268,7 +282,17 @@ func (a *Adapter) running(ref string) (*instance, error) {
return in, nil
}
-func (a *Adapter) Exec(_ context.Context, ref string, req api.ExecRequest) (api.ExecResult, error) {
+func (a *Adapter) Exec(ctx context.Context, ref string, req api.ExecRequest) (api.ExecResult, error) {
+ a.mu.Lock()
+ hold := a.HoldExecs
+ a.mu.Unlock()
+ if hold != nil {
+ select {
+ case <-hold:
+ case <-ctx.Done():
+ return api.ExecResult{}, ctx.Err()
+ }
+ }
a.mu.Lock()
defer a.mu.Unlock()
if _, err := a.running(ref); err != nil {
@@ -355,7 +379,24 @@ func (a *Adapter) Stop(_ context.Context, ref string) error {
func (a *Adapter) Start(_ context.Context, ref string) error {
return a.setState(ref, adapter.StateStopped, adapter.StateRunning)
}
-func (a *Adapter) Pause(_ context.Context, ref string) error {
+func (a *Adapter) Pause(ctx context.Context, ref string) error {
+ a.mu.Lock()
+ hold := a.HoldPauses
+ fail := a.FailPauses > 0
+ if fail {
+ a.FailPauses--
+ }
+ a.mu.Unlock()
+ if fail {
+ return adapter.Unavailable(errors.New("pause failed"))
+ }
+ if hold != nil {
+ select {
+ case <-hold:
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ }
return a.setState(ref, adapter.StateRunning, adapter.StatePaused)
}
func (a *Adapter) Resume(_ context.Context, ref string) error {
diff --git a/internal/adapters/fly/fly.go b/internal/adapters/fly/fly.go
index fefb7a4..a3d62df 100644
--- a/internal/adapters/fly/fly.go
+++ b/internal/adapters/fly/fly.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package fly adapts Fly.io's Machines API.
//
// Fly.io runs each machine as a Firecracker microVM on its own hosts, so
diff --git a/internal/adapters/fly/fly_test.go b/internal/adapters/fly/fly_test.go
index ec366c1..18ce9ca 100644
--- a/internal/adapters/fly/fly_test.go
+++ b/internal/adapters/fly/fly_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package fly
import (
diff --git a/internal/adapters/freestyle/backstop_test.go b/internal/adapters/freestyle/backstop_test.go
new file mode 100644
index 0000000..9d83909
--- /dev/null
+++ b/internal/adapters/freestyle/backstop_test.go
@@ -0,0 +1,39 @@
+package freestyle
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/different-ai/differently/pkg/adapter"
+)
+
+// Freestyle's deletion deadline counts from the create request and
+// Differently's TTL from the moment the VM is ready, so the deadline is sent
+// with a grace: Differently's own, audited expiry must run first.
+func TestFreestyleBackstopOutlastsTheTTL(t *testing.T) {
+ var body map[string]any
+ vm := map[string]any{"id": "vm-1", "state": "running", "metadata": map[string]string{metaSandbox: "sbx_ttl", metaInstallation: "inst_1"}}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v5/vms":
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ json.NewEncoder(w).Encode(vm)
+ case r.Method == http.MethodGet && body != nil:
+ json.NewEncoder(w).Encode(vm)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]string{"message": "not found"})
+ }
+ }))
+ defer srv.Close()
+ a := New(Options{APIURL: srv.URL, APIKey: "k", ReadyTimeout: 5 * time.Second})
+ // The VM comes up; later setup steps need endpoints this server lacks.
+ _, _ = a.Create(context.Background(), adapter.CreateRequest{ID: "sbx_ttl", Installation: "inst_1", TTLSeconds: 600})
+ if got := body["ttlSeconds"]; got != float64(600+backstopGrace) {
+ t.Fatalf("ttlSeconds %v for a 600 s TTL, want %d (the TTL plus the grace)", got, 600+backstopGrace)
+ }
+}
diff --git a/internal/adapters/freestyle/freestyle.go b/internal/adapters/freestyle/freestyle.go
index e524c7f..4b09539 100644
--- a/internal/adapters/freestyle/freestyle.go
+++ b/internal/adapters/freestyle/freestyle.go
@@ -35,6 +35,11 @@ import (
// DefaultAPIURL is Freestyle's API.
const DefaultAPIURL = "https://api.freestyle.sh"
+// backstopGrace keeps Freestyle's own deletion behind Differently's audited
+// expiry: Freestyle counts from the create request, Differently from the
+// moment the sandbox is ready.
+const backstopGrace = 5 * 60
+
// Metadata keys. Freestyle metadata keys are short and unprefixed.
const (
metaSandbox = "dly-sandbox"
@@ -101,7 +106,7 @@ func (a *Adapter) Info() adapter.Info {
func (a *Adapter) Capabilities(context.Context) (adapter.Capabilities, error) {
return adapter.Capabilities{Features: []string{
adapter.FeatureExec, adapter.FeatureFiles, adapter.FeatureStopStart, adapter.FeaturePersistDisk,
- adapter.FeaturePauseResume, adapter.FeatureSnapshotMem, adapter.FeatureSnapshotDisk, adapter.FeatureFork,
+ adapter.FeaturePauseResume, adapter.FeaturePauseCheckpoint, adapter.FeatureSnapshotMem, adapter.FeatureSnapshotDisk, adapter.FeatureFork,
adapter.FeaturePorts, adapter.FeatureEgressControl, adapter.FeatureMicroVM, adapter.FeatureArchAMD64,
}, MaxCPU: 32, MaxMemoryMiB: 65536, MaxDiskGiB: 256}, nil
}
@@ -175,7 +180,7 @@ func (a *Adapter) Create(ctx context.Context, req adapter.CreateRequest) (adapte
body["snapshotId"] = snapshot
}
if req.TTLSeconds > 0 {
- body["ttlSeconds"] = req.TTLSeconds // provider-side backstop
+ body["ttlSeconds"] = req.TTLSeconds + backstopGrace // provider-side backstop
}
err = a.c.JSON(ctx, http.MethodPost, "/v5/vms", body, &v)
if api.IsCode(err, api.CodeInvalidState) {
@@ -242,7 +247,11 @@ func (a *Adapter) List(ctx context.Context, installation string) ([]adapter.Inst
return nil, err
}
for _, v := range page.VMs {
- out = append(out, inst(v))
+ // Never trust a filter we did not write: the reconciler deletes
+ // what List returns and the control plane does not know.
+ if v.Metadata[metaInstallation] == installation && v.Metadata[metaSandbox] != "" {
+ out = append(out, inst(v))
+ }
}
offset += len(page.VMs)
if len(page.VMs) == 0 || offset >= page.TotalCount {
diff --git a/internal/adapters/freestyle/freestyle_test.go b/internal/adapters/freestyle/freestyle_test.go
index 5ddcc16..0900812 100644
--- a/internal/adapters/freestyle/freestyle_test.go
+++ b/internal/adapters/freestyle/freestyle_test.go
@@ -285,3 +285,29 @@ func TestFreestyleAdapter(t *testing.T) {
t.Fatalf("idempotent delete: %v", err)
}
}
+
+// The reconciler deletes what List returns and the control plane does not
+// know, so List must check ownership itself: a provider that ignores the
+// metadata filter returns every VM in the account.
+func TestFreestyleListIgnoresVMsItDoesNotOwn(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/v5/vms" {
+ http.NotFound(w, r)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"totalCount": 3, "vms": []any{
+ map[string]any{"id": "vm-mine", "state": "running", "metadata": map[string]string{metaInstallation: "inst_1", metaSandbox: "sbx_1"}},
+ map[string]any{"id": "vm-stranger", "state": "running", "metadata": map[string]string{}},
+ map[string]any{"id": "vm-other-install", "state": "running", "metadata": map[string]string{metaInstallation: "inst_other", metaSandbox: "sbx_theirs"}},
+ }})
+ }))
+ defer srv.Close()
+ a := New(Options{APIURL: srv.URL, APIKey: "k"})
+ list, err := a.List(context.Background(), "inst_1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 1 || list[0].Ref != "vm-mine" || list[0].SandboxID != "sbx_1" {
+ t.Fatalf("list returned VMs of other owners: %+v", list)
+ }
+}
diff --git a/internal/adapters/gcpsandboxes/gcpsandboxes.go b/internal/adapters/gcpsandboxes/gcpsandboxes.go
index 27fd262..62b3e41 100644
--- a/internal/adapters/gcpsandboxes/gcpsandboxes.go
+++ b/internal/adapters/gcpsandboxes/gcpsandboxes.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package gcpsandboxes adapts Google Cloud's Agent Platform sandbox
// environments (formerly Vertex AI Agent Engine code execution) in their
// shell flavour: each Differently sandbox is one sandbox environment under
diff --git a/internal/adapters/gcpsandboxes/gcpsandboxes_test.go b/internal/adapters/gcpsandboxes/gcpsandboxes_test.go
index 7eedd93..975c875 100644
--- a/internal/adapters/gcpsandboxes/gcpsandboxes_test.go
+++ b/internal/adapters/gcpsandboxes/gcpsandboxes_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package gcpsandboxes
import (
diff --git a/internal/adapters/morph/morph.go b/internal/adapters/morph/morph.go
index e8d9d97..bf0a8f2 100644
--- a/internal/adapters/morph/morph.go
+++ b/internal/adapters/morph/morph.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package morph adapts Morph Cloud's hosted VM API (/api).
//
// Morph Cloud is hosted-only, so pools on this adapter are always
diff --git a/internal/adapters/morph/morph_test.go b/internal/adapters/morph/morph_test.go
index 6e04fce..7546b42 100644
--- a/internal/adapters/morph/morph_test.go
+++ b/internal/adapters/morph/morph_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package morph
import (
diff --git a/internal/adapters/northflank/northflank.go b/internal/adapters/northflank/northflank.go
index 83ee0b7..592f64e 100644
--- a/internal/adapters/northflank/northflank.go
+++ b/internal/adapters/northflank/northflank.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package northflank adapts Northflank sandboxes: each Differently sandbox
// is one deployment service in a Northflank project, kept alive with
// `sleep infinity` so commands can run in it.
diff --git a/internal/adapters/northflank/northflank_test.go b/internal/adapters/northflank/northflank_test.go
index cc8b3de..7c4ab4a 100644
--- a/internal/adapters/northflank/northflank_test.go
+++ b/internal/adapters/northflank/northflank_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package northflank
import (
diff --git a/internal/adapters/northflank/websocket.go b/internal/adapters/northflank/websocket.go
index d9b9dba..5adc625 100644
--- a/internal/adapters/northflank/websocket.go
+++ b/internal/adapters/northflank/websocket.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package northflank
import (
diff --git a/internal/adapters/opensandbox/opensandbox.go b/internal/adapters/opensandbox/opensandbox.go
index 68e391e..cd43de9 100644
--- a/internal/adapters/opensandbox/opensandbox.go
+++ b/internal/adapters/opensandbox/opensandbox.go
@@ -82,11 +82,12 @@ var KubernetesFeatures = []string{
// FastSandboxFeatures are the defaults with runtime: fastsandbox. Fast Sandbox
// runs each sandbox in a Firecracker microVM, and its pause checkpoints the VM,
-// so memory and processes survive a pause and resume. snapshots.memory is not
+// so memory and processes survive a pause and resume, and a paused microVM
+// leaves the host (lifecycle.pause-checkpoint). snapshots.memory is not
// advertised: restoring a new sandbox from a memory snapshot is not verified.
var FastSandboxFeatures = []string{
adapter.FeatureExec, adapter.FeatureExecStream, adapter.FeatureFiles,
- adapter.FeaturePauseResume, adapter.FeatureSnapshotDisk, adapter.FeatureFork,
+ adapter.FeaturePauseResume, adapter.FeaturePauseCheckpoint, adapter.FeatureSnapshotDisk, adapter.FeatureFork,
}
// Options configures the adapter. Pool config keys: apiURL (required, the
@@ -152,6 +153,12 @@ type Options struct {
const runtimeFastSandbox = "fastsandbox"
+// backstopGrace keeps OpenSandbox's own timeout behind Differently's audited
+// expiry. The timeout starts at the create request and Differently's TTL only
+// when the sandbox is ready, so without it the backend deletes first and the
+// sandbox ends as "failed: backend instance disappeared" instead of expired.
+const backstopGrace = 5 * 60
+
// FromConfig maps pool config to options.
func FromConfig(cfg map[string]string, trust, apiKey string) (Options, error) {
o := Options{
@@ -514,7 +521,7 @@ func (a *Adapter) Create(ctx context.Context, req adapter.CreateRequest) (adapte
body["entrypoint"] = []string{"tail", "-f", "/dev/null"}
}
if req.TTLSeconds > 0 {
- body["timeout"] = max(req.TTLSeconds, 60) // provider-side backstop; minimum 60 s
+ body["timeout"] = req.TTLSeconds + backstopGrace // provider-side backstop
}
if err := a.call(ctx, http.MethodPost, "/sandboxes", body, &s); err != nil {
return adapter.Instance{}, createError(err)
@@ -558,7 +565,7 @@ func (a *Adapter) fastSandboxBody(req adapter.CreateRequest) (map[string]any, er
"timeout": a.opts.DefaultTimeout,
}
if req.TTLSeconds > 0 {
- body["timeout"] = max(req.TTLSeconds, 60)
+ body["timeout"] = req.TTLSeconds + backstopGrace
}
switch {
case req.FromSnapshot != "":
@@ -584,6 +591,11 @@ func (a *Adapter) fastSandboxBody(req adapter.CreateRequest) (map[string]any, er
return body, nil
}
+// CancelsPause reports that a resume during a Fast Sandbox pause calls it off:
+// the runtime serves until the checkpoint is durable, and a resume before it is
+// released keeps that runtime.
+func (a *Adapter) CancelsPause() bool { return a.opts.Runtime == runtimeFastSandbox }
+
// IdlesNatively reports that OpenSandbox pauses idle sandboxes itself and
// wakes them on access, which Fast Sandbox does when given an idle policy.
func (a *Adapter) IdlesNatively() bool { return a.opts.Runtime == runtimeFastSandbox }
@@ -1082,6 +1094,7 @@ var (
_ adapter.Streamer = (*Adapter)(nil)
_ adapter.FileSystem = (*Adapter)(nil)
_ adapter.Pauser = (*Adapter)(nil)
+ _ adapter.PauseCanceller = (*Adapter)(nil)
_ adapter.Lifecycle = (*Adapter)(nil)
_ adapter.Snapshotter = (*Adapter)(nil)
_ adapter.PortExposer = (*Adapter)(nil)
diff --git a/internal/adapters/opensandbox/opensandbox_test.go b/internal/adapters/opensandbox/opensandbox_test.go
index 4236b19..5be907c 100644
--- a/internal/adapters/opensandbox/opensandbox_test.go
+++ b/internal/adapters/opensandbox/opensandbox_test.go
@@ -291,7 +291,7 @@ func TestOpenSandboxLifecycleExecFiles(t *testing.T) {
}
sent := f.boxes[inst.Ref]["request"].(map[string]any)
limits := sent["resourceLimits"].(map[string]any)
- if limits["cpu"] != "2" || limits["memory"] != "1024Mi" || sent["timeout"].(float64) != 60 || sent["image"].(map[string]any)["uri"] != "python:3.12-slim" {
+ if limits["cpu"] != "2" || limits["memory"] != "1024Mi" || sent["timeout"].(float64) != 30+backstopGrace || sent["image"].(map[string]any)["uri"] != "python:3.12-slim" {
t.Fatalf("create body %v", sent)
}
if again, err := a.Create(ctx, req); err != nil || again.Ref != inst.Ref || f.creates != 1 {
@@ -425,6 +425,43 @@ func TestOpenSandboxCredentialsAndConfig(t *testing.T) {
}
}
+// A resume during a Fast Sandbox pause calls it off: the runtime still serves,
+// so resuming waits for it and for execd as after any other resume.
+func TestOpenSandboxFastSandboxCallsOffPauses(t *testing.T) {
+ f := newFake(t)
+ f.direct = true
+ ctx := context.Background()
+ opts, err := FromConfig(map[string]string{"apiURL": f.srv.URL + "/v1", "runtime": "fastsandbox", "templateId": "tpl_1"}, api.TrustPrivate, apiKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(opts)
+ if !a.CancelsPause() {
+ t.Fatal("Fast Sandbox calls pauses off")
+ }
+ in, err := a.Create(ctx, adapter.CreateRequest{ID: "sbx_1", Installation: "inst", Template: "tpl_1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.mu.Lock()
+ f.boxes[in.Ref]["status"] = map[string]any{"state": "Pausing"}
+ f.mu.Unlock()
+ if err := a.Resume(ctx, in.Ref); err != nil || f.resumes != 1 {
+ t.Fatalf("resume during a pause: %v (resumes %d)", err, f.resumes)
+ }
+ if got, _ := a.Get(ctx, in.Ref); got.State != adapter.StateRunning {
+ t.Fatalf("after calling the pause off: %+v", got)
+ }
+
+ docker, err := FromConfig(map[string]string{"apiURL": f.srv.URL + "/v1", "runtime": "docker"}, api.TrustPrivate, apiKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if New(docker).CancelsPause() {
+ t.Fatal("only Fast Sandbox calls pauses off")
+ }
+}
+
// Fast Sandbox: template-mode creates, OpenSandbox's native idle policy from
// Differently's idle timeout, execd reached directly so the gateway sees the
// traffic, and a sandbox the idle policy paused woken on the next call.
@@ -459,7 +496,7 @@ func TestOpenSandboxFastSandbox(t *testing.T) {
}
a := New(o)
caps, _ := a.Capabilities(ctx)
- if got := strings.Join(caps.Features, ","); got != "exec,exec.stream,files,lifecycle.pause-resume,snapshots.disk,fork,isolation.microvm" {
+ if got := strings.Join(caps.Features, ","); got != "exec,exec.stream,files,lifecycle.pause-resume,lifecycle.pause-checkpoint,snapshots.disk,fork,isolation.microvm" {
t.Fatalf("features %s", got)
}
if !a.IdlesNatively() || o.UseServerProxy {
@@ -514,7 +551,8 @@ func TestOpenSandboxFastSandbox(t *testing.T) {
t.Fatalf("paused microVM reported as %s", got.State)
}
- // No idle timeout: no policy, and a TTL becomes the timeout.
+ // No idle timeout: no policy, and the TTL plus the backstop grace becomes
+ // the timeout, so Differently's own expiry runs first.
in2, err := a.Create(ctx, adapter.CreateRequest{ID: "sbx_2", Installation: "inst", TTLSeconds: 600, Template: "tpl_2"})
if err != nil {
t.Fatal(err)
@@ -522,7 +560,7 @@ func TestOpenSandboxFastSandbox(t *testing.T) {
f.mu.Lock()
req = f.boxes[in2.Ref]["request"].(map[string]any)
f.mu.Unlock()
- if _, ok := req["lifecycle"]; ok || req["timeout"] != float64(600) || req["templateId"] != "tpl_2" {
+ if _, ok := req["lifecycle"]; ok || req["timeout"] != float64(600+backstopGrace) || req["templateId"] != "tpl_2" {
t.Fatalf("create without idle %v", req)
}
diff --git a/internal/adapters/pyshell/pyshell.go b/internal/adapters/pyshell/pyshell.go
index 3797f3f..a71ce17 100644
--- a/internal/adapters/pyshell/pyshell.go
+++ b/internal/adapters/pyshell/pyshell.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package pyshell runs Differently exec requests on code-interpreter
// backends that execute Python rather than shell commands. The request
// becomes a short Python program that runs the command with subprocess,
diff --git a/internal/adapters/pyshell/pyshell_test.go b/internal/adapters/pyshell/pyshell_test.go
index bfe8b53..ac0c85c 100644
--- a/internal/adapters/pyshell/pyshell_test.go
+++ b/internal/adapters/pyshell/pyshell_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package pyshell
import (
diff --git a/internal/adapters/shellcmd/shellcmd.go b/internal/adapters/shellcmd/shellcmd.go
index 6beca95..1b89c52 100644
--- a/internal/adapters/shellcmd/shellcmd.go
+++ b/internal/adapters/shellcmd/shellcmd.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package shellcmd builds POSIX shell scripts for backends whose only
// primitive is "run this command line": the exec request's working
// directory, environment, stdin and timeout become part of the script, and
diff --git a/internal/adapters/shellcmd/shellcmd_test.go b/internal/adapters/shellcmd/shellcmd_test.go
index b347313..86704e2 100644
--- a/internal/adapters/shellcmd/shellcmd_test.go
+++ b/internal/adapters/shellcmd/shellcmd_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package shellcmd
import (
diff --git a/internal/adapters/vercel/vercel.go b/internal/adapters/vercel/vercel.go
index 8085163..8478ccb 100644
--- a/internal/adapters/vercel/vercel.go
+++ b/internal/adapters/vercel/vercel.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
// Package vercel adapts Vercel Sandbox's REST API.
//
// Vercel runs every sandbox as a Firecracker microVM it operates, so pools on
diff --git a/internal/adapters/vercel/vercel_test.go b/internal/adapters/vercel/vercel_test.go
index 144d464..cae8b26 100644
--- a/internal/adapters/vercel/vercel_test.go
+++ b/internal/adapters/vercel/vercel_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package vercel
import (
diff --git a/internal/capsim/capsim.go b/internal/capsim/capsim.go
new file mode 100644
index 0000000..d0a0a38
--- /dev/null
+++ b/internal/capsim/capsim.go
@@ -0,0 +1,350 @@
+// Package capsim simulates one pool's slots under the scheduler's capacity
+// strategies, to put numbers on them. It is a model, not the scheduler: each
+// user owns one long-lived computer (as OpenWork's Workbot does) that is
+// created at the user's first session, runs while the user works, pauses
+// after an idle timeout and resumes at the next session. Pauses take time to
+// write their checkpoint; the slot is free only once they finish.
+//
+// Strategies:
+//
+// - hold: a paused computer keeps its slot (what Differently did before),
+// so the pool holds at most Slots computers and later users' computers are
+// created on the external overflow pool.
+// - release: a paused computer on a checkpointing pool gives its slot
+// back; a resume on a full pool waits in a queue.
+// - preempt-idle: release, plus a running computer idle for PreemptIdle (and
+// not in a session) is paused early to make room for waiting work.
+// - preempt-lru: release, plus the least recently used running computer,
+// busy or not, is paused to make room (never one that ran less than
+// MinRun).
+//
+// A create that finds no free slot overflows to the external pool, which has
+// no limit; a resume stays on its pool, where its memory is.
+package capsim
+
+import (
+ "math"
+ "math/rand/v2"
+ "sort"
+)
+
+// Strategy is a capacity strategy.
+type Strategy string
+
+// Strategies.
+const (
+ Hold Strategy = "hold"
+ Release Strategy = "release"
+ PreemptIdle Strategy = "preempt-idle"
+ PreemptLRU Strategy = "preempt-lru"
+)
+
+// Config describes one simulated pool and its users. Times are in seconds.
+type Config struct {
+ Strategy Strategy
+ Slots int
+ Users int
+ Days int
+ // Sessions a user starts per working day (Poisson), in an 8-hour day.
+ SessionsPerDay float64
+ // Session length: log-normal with this median and sigma.
+ SessionMedian float64
+ SessionSigma float64
+ IdlePause int // pause a computer idle this long
+ PauseTime int // how long a pause takes to free the slot
+ ResumeTime int // how long a resume takes once it holds a slot
+ MinRun int // preemption never picks a computer that ran less
+ PreemptIdle int // preempt-idle picks computers idle this long
+ Seed uint64
+}
+
+// Workbot returns OpenWork Workbot's settings on the Azure Fast Sandbox pool:
+// a 5-minute idle pause, a checkpoint pause of 182 s (the median for an
+// in-use desktop with sparse templates, docs/research/fast-sandbox-pause-time.md)
+// and a resume of about 2 s.
+func Workbot(s Strategy, slots, users int) Config {
+ return Config{
+ Strategy: s, Slots: slots, Users: users, Days: 5,
+ SessionsPerDay: 6, SessionMedian: 12 * 60, SessionSigma: 0.9,
+ IdlePause: 300, PauseTime: 182, ResumeTime: 2, MinRun: 600, PreemptIdle: 60, Seed: 1,
+ }
+}
+
+// Result is what one run measured.
+type Result struct {
+ Config Config
+ // PrivateUsers is the share of users whose computer is on the pool.
+ PrivateUsers float64
+ // ExternalHours is computer-hours run on the overflow pool.
+ ExternalHours float64
+ // Utilization is busy slot time over all slot time, on the pool.
+ Utilization float64
+ // Wait percentiles: seconds from a session's start until its computer
+ // runs, over every session (create, resume, or already running).
+ WaitP50, WaitP95, WaitMax float64
+ // Preemptions per day, and how many paused a computer during a session.
+ PreemptionsPerDay, InterruptionsPerDay float64
+}
+
+type state int
+
+const (
+ none state = iota
+ running
+ pausing
+ paused
+ resuming
+)
+
+type computer struct {
+ external bool
+ state state
+ busy bool
+ lastActive int
+ runningSince int
+ until int // end of pausing or resuming
+ queuedAt int // resume waiting for a slot; -1 when not
+ waitFrom int // session waiting for this computer; -1 when not
+}
+
+type session struct{ start, end, user int }
+
+// Run simulates cfg.
+func Run(cfg Config) Result {
+ rng := rand.New(rand.NewPCG(cfg.Seed, 7))
+ horizon := cfg.Days * 86400
+ var sessions []session
+ for u := 0; u < cfg.Users; u++ {
+ for d := 0; d < cfg.Days; d++ {
+ day := d*86400 + 9*3600
+ n := poisson(rng, cfg.SessionsPerDay)
+ for i := 0; i < n; i++ {
+ start := day + rng.IntN(8*3600)
+ length := int(cfg.SessionMedian * math.Exp(cfg.SessionSigma*rng.NormFloat64()))
+ sessions = append(sessions, session{start, start + max(length, 30), u})
+ }
+ }
+ }
+ starts := map[int][]session{}
+ ends := map[int][]int{}
+ for _, s := range sessions {
+ starts[s.start] = append(starts[s.start], s)
+ ends[s.end] = append(ends[s.end], s.user)
+ }
+ cs := make([]computer, cfg.Users)
+ for i := range cs {
+ cs[i].queuedAt, cs[i].waitFrom = -1, -1
+ }
+ holds := func(c *computer) bool {
+ if c.external || c.state == none {
+ return false
+ }
+ if c.state == paused && cfg.Strategy != Hold {
+ return false
+ }
+ return true
+ }
+ held := func() int {
+ n := 0
+ for i := range cs {
+ if holds(&cs[i]) {
+ n++
+ }
+ }
+ return n
+ }
+ var waits []float64
+ var busySlotSeconds, externalSeconds float64
+ preemptions, interruptions := 0, 0
+ var queue []int
+ privateUsers := 0
+
+ resume := func(u, t int) {
+ c := &cs[u]
+ c.state, c.until, c.queuedAt = resuming, t+cfg.ResumeTime, -1
+ }
+ for t := 0; t < horizon; t++ {
+ // Finish pauses and resumes.
+ for u := range cs {
+ c := &cs[u]
+ switch {
+ case c.state == pausing && t >= c.until:
+ c.state = paused
+ if c.waitFrom >= 0 {
+ c.queuedAt = t // the session came back during the pause
+ }
+ case c.state == resuming && t >= c.until:
+ c.state, c.runningSince = running, t
+ if c.waitFrom >= 0 {
+ waits = append(waits, float64(t-c.waitFrom))
+ c.waitFrom = -1
+ }
+ }
+ }
+ // Sessions end and start.
+ for _, u := range ends[t] {
+ cs[u].busy, cs[u].lastActive = false, t
+ }
+ for _, s := range starts[t] {
+ c := &cs[s.user]
+ if c.busy {
+ continue // overlapping sessions of one user share the computer
+ }
+ c.busy, c.lastActive = true, t
+ switch c.state {
+ case none:
+ c.external = held() >= cfg.Slots
+ if !c.external {
+ privateUsers++
+ }
+ c.state, c.until, c.waitFrom = resuming, t+cfg.ResumeTime, t
+ case running, resuming:
+ if c.state == running {
+ waits = append(waits, 0)
+ } else if c.waitFrom < 0 {
+ c.waitFrom = t
+ }
+ case pausing:
+ c.waitFrom = t
+ case paused:
+ c.waitFrom = t
+ c.queuedAt = t
+ }
+ }
+ // Queue resumes; serve in order while slots are free.
+ for u := range cs {
+ c := &cs[u]
+ if c.state == paused && c.queuedAt >= 0 && !contains(queue, u) {
+ if c.external || cfg.Strategy == Hold {
+ resume(u, t)
+ continue
+ }
+ queue = append(queue, u)
+ }
+ }
+ for len(queue) > 0 && held() < cfg.Slots {
+ resume(queue[0], t)
+ queue = queue[1:]
+ }
+ // Idle pauses.
+ for u := range cs {
+ c := &cs[u]
+ if c.state == running && !c.busy && t-c.lastActive >= cfg.IdlePause {
+ c.state, c.until = pausing, t+cfg.PauseTime
+ }
+ }
+ // Preemption: one victim per waiting resume, counting pauses under way.
+ if (cfg.Strategy == PreemptIdle || cfg.Strategy == PreemptLRU) && len(queue) > 0 {
+ inflight := 0
+ for u := range cs {
+ if !cs[u].external && cs[u].state == pausing {
+ inflight++
+ }
+ }
+ for need := len(queue) - inflight; need > 0; need-- {
+ v := -1
+ for u := range cs {
+ c := &cs[u]
+ if c.external || c.state != running {
+ continue
+ }
+ if cfg.Strategy == PreemptIdle && (c.busy || t-c.lastActive < cfg.PreemptIdle) {
+ continue
+ }
+ if cfg.Strategy == PreemptLRU && t-c.runningSince < cfg.MinRun {
+ continue
+ }
+ if v < 0 || c.lastActive < cs[v].lastActive {
+ v = u
+ }
+ }
+ if v < 0 {
+ break
+ }
+ preemptions++
+ if cs[v].busy {
+ interruptions++
+ cs[v].waitFrom = t
+ }
+ cs[v].state, cs[v].until = pausing, t+cfg.PauseTime
+ }
+ }
+ // Accounting.
+ for u := range cs {
+ c := &cs[u]
+ if c.state == none || c.state == paused {
+ continue
+ }
+ if c.external {
+ externalSeconds++
+ } else if c.busy {
+ busySlotSeconds++
+ }
+ }
+ }
+ sort.Float64s(waits)
+ days := float64(cfg.Days)
+ return Result{
+ Config: cfg,
+ PrivateUsers: float64(privateUsers) / float64(cfg.Users),
+ ExternalHours: externalSeconds / 3600,
+ Utilization: busySlotSeconds / float64(cfg.Slots*horizon),
+ WaitP50: pct(waits, 0.5),
+ WaitP95: pct(waits, 0.95),
+ WaitMax: pct(waits, 1),
+ PreemptionsPerDay: float64(preemptions) / days,
+ InterruptionsPerDay: float64(interruptions) / days,
+ }
+}
+
+func poisson(rng *rand.Rand, lambda float64) int {
+ l, k, p := math.Exp(-lambda), 0, 1.0
+ for {
+ p *= rng.Float64()
+ if p <= l {
+ return k
+ }
+ k++
+ }
+}
+
+func pct(sorted []float64, q float64) float64 {
+ if len(sorted) == 0 {
+ return 0
+ }
+ i := int(math.Ceil(q*float64(len(sorted)))) - 1
+ return sorted[max(0, min(i, len(sorted)-1))]
+}
+
+func contains(xs []int, x int) bool {
+ for _, v := range xs {
+ if v == x {
+ return true
+ }
+ }
+ return false
+}
+
+// Average runs cfg with seeds 1..n and averages the results.
+func Average(cfg Config, n int) Result {
+ var sum Result
+ for seed := 1; seed <= n; seed++ {
+ cfg.Seed = uint64(seed)
+ r := Run(cfg)
+ sum.PrivateUsers += r.PrivateUsers
+ sum.ExternalHours += r.ExternalHours
+ sum.Utilization += r.Utilization
+ sum.WaitP50 += r.WaitP50
+ sum.WaitP95 += r.WaitP95
+ sum.WaitMax = max(sum.WaitMax, r.WaitMax)
+ sum.PreemptionsPerDay += r.PreemptionsPerDay
+ sum.InterruptionsPerDay += r.InterruptionsPerDay
+ }
+ k := float64(n)
+ cfg.Seed = 0
+ return Result{
+ Config: cfg, PrivateUsers: sum.PrivateUsers / k, ExternalHours: sum.ExternalHours / k, Utilization: sum.Utilization / k,
+ WaitP50: sum.WaitP50 / k, WaitP95: sum.WaitP95 / k, WaitMax: sum.WaitMax,
+ PreemptionsPerDay: sum.PreemptionsPerDay / k, InterruptionsPerDay: sum.InterruptionsPerDay / k,
+ }
+}
diff --git a/internal/capsim/capsim_test.go b/internal/capsim/capsim_test.go
new file mode 100644
index 0000000..226680b
--- /dev/null
+++ b/internal/capsim/capsim_test.go
@@ -0,0 +1,33 @@
+package capsim
+
+import "testing"
+
+// The findings docs/scheduling/capacity.md reports, pinned so a model change
+// that overturns them fails here.
+func TestStrategiesCompare(t *testing.T) {
+ run := func(s Strategy) Result { return Average(Workbot(s, 4, 12), 5) }
+ hold, release, idle, lru := run(Hold), run(Release), run(PreemptIdle), run(PreemptLRU)
+
+ if release.PrivateUsers < 2*hold.PrivateUsers || release.ExternalHours > 0.4*hold.ExternalHours {
+ t.Errorf("releasing paused slots should at least double the users the pool serves and halve overflow: hold %+v release %+v", hold, release)
+ }
+ if release.Utilization < 1.5*hold.Utilization {
+ t.Errorf("releasing should raise utilization: hold %.3f release %.3f", hold.Utilization, release.Utilization)
+ }
+ if release.WaitP95 <= hold.WaitP95 {
+ t.Errorf("the price of releasing is waiting for a slot: hold p95 %.0f release p95 %.0f", hold.WaitP95, release.WaitP95)
+ }
+ if idle.WaitP95 >= release.WaitP95 || idle.InterruptionsPerDay != 0 {
+ t.Errorf("idle preemption should shorten the wait without interrupting sessions: release %+v idle %+v", release, idle)
+ }
+ if lru.InterruptionsPerDay == 0 {
+ t.Errorf("lru preemption interrupts sessions: %+v", lru)
+ }
+}
+
+func TestDeterministic(t *testing.T) {
+ a, b := Run(Workbot(Release, 4, 8)), Run(Workbot(Release, 4, 8))
+ if a != b {
+ t.Fatalf("same seed, different results: %+v %+v", a, b)
+ }
+}
diff --git a/internal/config/build_default.go b/internal/config/build_default.go
new file mode 100644
index 0000000..190d9f5
--- /dev/null
+++ b/internal/config/build_default.go
@@ -0,0 +1,7 @@
+//go:build !experimental_adapters
+
+package config
+
+// experimentalBuilt is false in the default build, which leaves the
+// ExperimentalAdapters out.
+const experimentalBuilt = false
diff --git a/internal/config/build_experimental.go b/internal/config/build_experimental.go
new file mode 100644
index 0000000..0f6d500
--- /dev/null
+++ b/internal/config/build_experimental.go
@@ -0,0 +1,7 @@
+//go:build experimental_adapters
+
+package config
+
+// experimentalBuilt is true when the server is built with
+// -tags experimental_adapters.
+const experimentalBuilt = true
diff --git a/internal/config/cloud_kinds_test.go b/internal/config/cloud_kinds_test.go
index e79ed26..d8e4632 100644
--- a/internal/config/cloud_kinds_test.go
+++ b/internal/config/cloud_kinds_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package config
import (
diff --git a/internal/config/config.go b/internal/config/config.go
index 75700b1..62346b7 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -72,6 +72,13 @@ type Defaults struct {
// IdleTimeoutSeconds stops or pauses sandboxes left idle this long
// (0: off unless a request asks). At least 60 when set.
IdleTimeoutSeconds int `json:"idleTimeoutSeconds"`
+ // MaxRunSeconds pauses (or stops) sandboxes that have run this long
+ // without a break, so long runners give their slot back (0: off unless a
+ // request asks). At least 60 when set.
+ MaxRunSeconds int `json:"maxRunSeconds"`
+ // MaxPausedSeconds deletes sandboxes left paused or stopped this long
+ // (0: off unless a request asks). At least 60 when set.
+ MaxPausedSeconds int `json:"maxPausedSeconds"`
}
// CredentialRef points at a secret without containing it.
@@ -145,6 +152,9 @@ type PoolConfig struct {
CostModel api.CostModel `json:"costModel,omitempty"`
Config map[string]string `json:"config,omitempty"`
Credentials CredentialRef `json:"credentials,omitempty"`
+ // Preemption lets the scheduler pause running sandboxes to make room for
+ // waiting work (pools that checkpoint on pause only).
+ Preemption *api.Preemption `json:"preemption,omitempty"`
}
// ToPool converts to the API representation.
@@ -153,8 +163,24 @@ func (p PoolConfig) ToPool() api.Pool {
return api.Pool{
Name: p.Name, Adapter: p.Adapter, Trust: p.Trust, Region: p.Region, Labels: p.Labels,
Enabled: enabled, MaxConcurrency: p.MaxConcurrency, CostModel: p.CostModel, Config: p.Config,
- CredentialRef: p.Credentials.String(), Source: "config",
+ CredentialRef: p.Credentials.String(), Source: "config", Preemption: p.Preemption,
+ }
+}
+
+// ValidatePreemption checks a pool's preemption policy.
+func ValidatePreemption(p *api.Preemption) error {
+ if p == nil {
+ return nil
+ }
+ switch p.Mode {
+ case "", api.PreemptOff, api.PreemptIdle, api.PreemptLRU:
+ default:
+ return fmt.Errorf("preemption.mode must be off, idle or lru, not %q", p.Mode)
+ }
+ if p.MinRunSeconds < 0 || p.IdleSeconds < 0 {
+ return fmt.Errorf("preemption seconds must not be negative")
}
+ return nil
}
// SchedulerConfig tunes the dispatch and reconcile loops.
@@ -211,8 +237,45 @@ func (d DashboardConfig) On() bool { return d.Enabled == nil || *d.Enabled }
// Adapter kinds that always run on third-party infrastructure.
var externalKinds = []string{"daytona", "freestyle", "vercel", "fly", "boat", "morph", "blaxel"}
-// KnownAdapters lists adapter kinds the server can build.
-var KnownAdapters = []string{"opensandbox", "daytona", "freestyle", "remote", "devhost", "fake", "vercel", "fly", "agent-sandbox", "cubesandbox", "aws-agentcore", "azure-sandboxes", "azure-sessions", "gcp-sandboxes", "northflank", "boat", "e2b", "morph", "blaxel"}
+// DefaultAdapters lists the adapter kinds in every build: the backends
+// Differently has run against for real, the remote plugin protocol, and the
+// two dev-mode kinds.
+var DefaultAdapters = []string{"opensandbox", "daytona", "freestyle", "remote", "devhost", "fake"}
+
+// ExperimentalTag is the Go build tag that compiles ExperimentalAdapters in.
+const ExperimentalTag = "experimental_adapters"
+
+// ExperimentalAdapters lists the adapter kinds left out of the default build.
+// Apart from agent-sandbox, which ran live against a kind cluster, they are
+// tested only against fakes of each provider's published API. A server built
+// with -tags experimental_adapters has them.
+var ExperimentalAdapters = []string{"vercel", "fly", "agent-sandbox", "cubesandbox", "aws-agentcore", "azure-sandboxes", "azure-sessions", "gcp-sandboxes", "northflank", "boat", "e2b", "morph", "blaxel"}
+
+// KnownAdapters lists adapter kinds this binary can build.
+var KnownAdapters = knownAdapters()
+
+func knownAdapters() []string {
+ if experimentalBuilt {
+ return slices.Concat(DefaultAdapters, ExperimentalAdapters)
+ }
+ return slices.Clone(DefaultAdapters)
+}
+
+// ExperimentalBuilt reports whether this binary has the experimental adapters.
+func ExperimentalBuilt() bool { return experimentalBuilt }
+
+// UnavailableAdapter says why this binary cannot build an adapter of kind:
+// an experimental kind names the build tag that adds it. It returns "" for a
+// kind in KnownAdapters.
+func UnavailableAdapter(kind string) string {
+ switch {
+ case slices.Contains(KnownAdapters, kind):
+ return ""
+ case slices.Contains(ExperimentalAdapters, kind):
+ return fmt.Sprintf("the %s adapter is experimental and not in this build of differently-server; build the server with -tags %s to use it (docs/providers/README.md)", kind, ExperimentalTag)
+ }
+ return fmt.Sprintf("unknown adapter %q (known: %s)", kind, strings.Join(KnownAdapters, ", "))
+}
// Adapter kinds that run where the operator put them (a plugin they deploy,
// the dev host, the test fake), so they are private unless declared.
@@ -352,6 +415,16 @@ func (c Config) Validate() error {
}
var errs []error
bad := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
+ for name, d := range map[string]int{"maxRunSeconds": c.Defaults.MaxRunSeconds, "maxPausedSeconds": c.Defaults.MaxPausedSeconds} {
+ if d != 0 && d < 60 {
+ bad("defaults.%s must be 0 (off) or at least 60, not %d", name, d)
+ }
+ }
+ for _, p := range c.Pools {
+ if err := ValidatePreemption(p.Preemption); err != nil {
+ bad("pool %s: %v", p.Name, err)
+ }
+ }
for _, o := range c.CORS.AllowedOrigins {
u, err := url.Parse(o)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || strings.Contains(u.Host, "*") || (u.Path != "" && u.Path != "/") || u.RawQuery != "" {
@@ -390,8 +463,8 @@ func (c Config) Validate() error {
bad("duplicate pool %s", p.Name)
}
names[p.Name] = true
- if !slices.Contains(KnownAdapters, p.Adapter) {
- bad("pool %s: unknown adapter %q (known: %s)", p.Name, p.Adapter, strings.Join(KnownAdapters, ", "))
+ if why := UnavailableAdapter(p.Adapter); why != "" {
+ bad("pool %s: %s", p.Name, why)
}
switch {
case p.Trust == "":
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 96ec7a4..06b57b0 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -37,8 +37,8 @@ pools:
func TestManagedProvidersAreAlwaysExternal(t *testing.T) {
c, err := Parse([]byte(`
pools:
- - {name: bl, adapter: blaxel, trust: private, config: {workspace: ws}, credentials: {env: BL_API_KEY}}
- - {name: bl2, adapter: blaxel, config: {workspace: ws}}
+ - {name: fs, adapter: freestyle, trust: private, credentials: {env: FREESTYLE_API_KEY}}
+ - {name: fs2, adapter: freestyle}
`))
if err != nil {
t.Fatal(err)
@@ -103,10 +103,10 @@ func TestNewAdapterKindsMustDeclareTrust(t *testing.T) {
}
}
-// Every adapter kind the server can build sits in exactly one trust list, so
-// adding a kind means deciding who operates it.
+// Every adapter kind, in this build or behind the experimental tag, sits in
+// exactly one trust list, so adding a kind means deciding who operates it.
func TestEveryAdapterKindHasATrustRule(t *testing.T) {
- for _, kind := range KnownAdapters {
+ for _, kind := range slices.Concat(DefaultAdapters, ExperimentalAdapters) {
n := 0
for _, list := range [][]string{externalKinds, privateByDefaultKinds, declaredTrustKinds} {
if slices.Contains(list, kind) {
@@ -119,32 +119,6 @@ func TestEveryAdapterKindHasATrustRule(t *testing.T) {
}
}
-// Managed providers are external whatever a pool says; E2B, which can also
-// be self-hosted, must declare who operates it and is private only when it
-// says so.
-func TestProviderKindsTrust(t *testing.T) {
- pool := func(kind, trust string) []byte {
- return []byte("pools:\n - name: p\n adapter: " + kind + "\n" + trust + " credentials: {env: KEY}\n")
- }
- for _, kind := range []string{"boat", "morph"} {
- for _, trust := range []string{"", " trust: private\n"} {
- c, err := Parse(pool(kind, trust))
- if err != nil || c.Pools[0].Trust != api.TrustExternal {
- t.Errorf("%s with %q: %+v %v", kind, trust, c.Pools, err)
- }
- }
- }
- if _, err := Parse(pool("e2b", "")); err == nil || !strings.Contains(err.Error(), "set trust explicitly") {
- t.Fatalf("e2b without trust must be rejected, got %v", err)
- }
- for _, trust := range []string{api.TrustExternal, api.TrustPrivate} {
- c, err := Parse(pool("e2b", " trust: "+trust+"\n"))
- if err != nil || c.Pools[0].Trust != trust {
- t.Errorf("e2b declared %s: %+v %v", trust, c.Pools, err)
- }
- }
-}
-
// CORS origins must be exact origins, never wildcards or URLs with paths.
func TestCORSOriginsMustBeExact(t *testing.T) {
for _, o := range []string{"*", "https://*.example.com", "console.example.com", "https://console.example.com/app", "ftp://x.example"} {
diff --git a/internal/config/default_kinds_test.go b/internal/config/default_kinds_test.go
new file mode 100644
index 0000000..5f1d640
--- /dev/null
+++ b/internal/config/default_kinds_test.go
@@ -0,0 +1,36 @@
+//go:build !experimental_adapters
+
+package config
+
+import (
+ "slices"
+ "strings"
+ "testing"
+)
+
+// The default build holds only the proven kinds. A pool of an experimental
+// kind is refused with the build tag that adds it, never accepted and left to
+// fail later.
+func TestDefaultBuildRefusesExperimentalKinds(t *testing.T) {
+ if ExperimentalBuilt() {
+ t.Fatal("built without the tag, but ExperimentalBuilt is true")
+ }
+ if want := []string{"opensandbox", "daytona", "freestyle", "remote", "devhost", "fake"}; !slices.Equal(KnownAdapters, want) {
+ t.Fatalf("default build knows %v, want %v", KnownAdapters, want)
+ }
+ for _, kind := range ExperimentalAdapters {
+ _, err := Parse([]byte("pools:\n - {name: p, adapter: " + kind + ", trust: external, config: {apiURL: \"http://x\"}, credentials: {env: KEY}}\n"))
+ if err == nil {
+ t.Errorf("%s: accepted by a build that has no such adapter", kind)
+ continue
+ }
+ for _, want := range []string{"the " + kind + " adapter is experimental", "-tags experimental_adapters"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("%s: error %q does not say %q", kind, err, want)
+ }
+ }
+ if strings.Contains(err.Error(), "unknown adapter") {
+ t.Errorf("%s: reported as unknown, not as left out of the build: %v", kind, err)
+ }
+ }
+}
diff --git a/internal/config/experimental_kinds_test.go b/internal/config/experimental_kinds_test.go
new file mode 100644
index 0000000..5a82f85
--- /dev/null
+++ b/internal/config/experimental_kinds_test.go
@@ -0,0 +1,49 @@
+//go:build experimental_adapters
+
+package config
+
+import (
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// With the tag, every experimental kind is one the server can build.
+func TestExperimentalKindsAreKnown(t *testing.T) {
+ if !ExperimentalBuilt() {
+ t.Fatal("built with the tag, but ExperimentalBuilt is false")
+ }
+ for _, kind := range ExperimentalAdapters {
+ if !slices.Contains(KnownAdapters, kind) || UnavailableAdapter(kind) != "" {
+ t.Errorf("%s should be available in a build with -tags %s", kind, ExperimentalTag)
+ }
+ }
+}
+
+// Managed providers are external whatever a pool says; E2B, which can also
+// be self-hosted, must declare who operates it and is private only when it
+// says so.
+func TestProviderKindsTrust(t *testing.T) {
+ pool := func(kind, trust string) []byte {
+ return []byte("pools:\n - name: p\n adapter: " + kind + "\n" + trust + " credentials: {env: KEY}\n")
+ }
+ for _, kind := range []string{"boat", "morph"} {
+ for _, trust := range []string{"", " trust: private\n"} {
+ c, err := Parse(pool(kind, trust))
+ if err != nil || c.Pools[0].Trust != api.TrustExternal {
+ t.Errorf("%s with %q: %+v %v", kind, trust, c.Pools, err)
+ }
+ }
+ }
+ if _, err := Parse(pool("e2b", "")); err == nil || !strings.Contains(err.Error(), "set trust explicitly") {
+ t.Fatalf("e2b without trust must be rejected, got %v", err)
+ }
+ for _, trust := range []string{api.TrustExternal, api.TrustPrivate} {
+ c, err := Parse(pool("e2b", " trust: "+trust+"\n"))
+ if err != nil || c.Pools[0].Trust != trust {
+ t.Errorf("e2b declared %s: %+v %v", trust, c.Pools, err)
+ }
+ }
+}
diff --git a/internal/config/platform_adapters_test.go b/internal/config/platform_adapters_test.go
index aa087b5..cfdc3ce 100644
--- a/internal/config/platform_adapters_test.go
+++ b/internal/config/platform_adapters_test.go
@@ -1,3 +1,5 @@
+//go:build experimental_adapters
+
package config
import (
diff --git a/internal/dashboard/static/app.js b/internal/dashboard/static/app.js
index 0477e1b..838e094 100644
--- a/internal/dashboard/static/app.js
+++ b/internal/dashboard/static/app.js
@@ -28,26 +28,15 @@ function table(cols, rows) {
const status = (s) => `${esc(s)} `;
const tokenBox = (t) => `Store it now; it is not shown again. ${esc(t)}Copy
`;
-// Provider kinds the console can add: label, fixed trust (empty: the operator
-// declares it), and main config fields as [key, label, choices?, required?].
+// Provider kinds the console can add (those in the default server build; the
+// experimental kinds are added with the CLI, the API or the config file):
+// label, fixed trust (empty: the operator declares it), and main config fields
+// as [key, label, choices?, required?].
const kinds = {
daytona: ["Daytona", "external", [["apiURL", "API URL (default https://app.daytona.io/api)"], ["target", "target region"], ["snapshot", "snapshot"], ["class", "class", ["container", "linux-vm"]]]],
freestyle: ["Freestyle", "external", [["apiURL", "API URL (default https://api.freestyle.sh)"], ["snapshot", "snapshot"], ["egress", "egress", ["none", "public"]], ["domain", "domain (default style.dev)"]]],
- boat: ["Boat", "external", [["apiURL", "API URL (default https://boat.dev/api/v1)"], ["type", "smallest size", ["small", "default", "large", "xlarge"]], ["snapshot", "saved snapshot"], ["environment", "environment"], ["org", "organization"], ["autoStopSeconds", "auto-stop seconds for sandboxes without a TTL"], ["portAccess", "port URLs", ["token", "public"]]]],
- e2b: ["E2B", "", [["apiURL", "API URL (default https://api.e2b.app)"], ["domain", "domain (default e2b.app)"], ["template", "template (default base)"], ["maxLifetimeSeconds", "plan's maximum runtime in seconds (default 3600)"], ["ports", "ports", ["private", "public"]], ["egress", "egress", ["public", "none"]]]],
- morph: ["Morph Cloud", "external", [["apiURL", "API URL (default https://cloud.morph.so/api)"], ["snapshot", "snapshot"], ["image", "image (default morphvm-minimal)"], ["portAuth", "port auth", ["none", "api_key"]]]],
- blaxel: ["Blaxel", "external", [["workspace", "workspace", null, true], ["apiURL", "API URL (default https://api.blaxel.ai/v0)"], ["region", "region (e.g. us-pdx-1)"], ["image", "image (default blaxel/base-image:latest)"]]],
opensandbox: ["OpenSandbox", "", [["apiURL", "lifecycle API URL", null, true], ["image", "image"], ["templateId", "template id"], ["isolation", "isolation", ["container", "microvm"]], ["runtime", "runtime", ["docker", "kubernetes"]], ["lineEndings", "line endings", ["kept", "stripped"]], ["ports", "port URLs", ["signed"]], ["egress", "egress", ["none", "public", "allowlist"]], ["egressAllow", "egress allowlist (comma-separated hosts)"], ["useServerProxy", "server proxy", ["true", "false"]]]],
remote: ["Remote plugin", "", [["endpoint", "plugin endpoint URL", null, true]]],
- vercel: ["Vercel Sandbox", "external", [["project", "project ID or name", null, true], ["team", "team ID"], ["region", "region (default iad1)"], ["image", "image"], ["architecture", "architecture", ["amd64", "arm64"]], ["ports", "ports (comma separated)"], ["maxTimeoutSeconds", "max session seconds (default 2700)"]]],
- fly: ["Fly.io Machines", "external", [["app", "Fly app name", null, true], ["region", "region"], ["image", "image"], ["cpuKind", "CPU kind", ["shared", "performance"]], ["suspend", "suspend for pause", ["true", "false"]]]],
- "agent-sandbox": ["agent-sandbox (Kubernetes)", "", [["namespace", "namespace (default default)"], ["apiServer", "API server URL (default: in-cluster)"], ["caFile", "API server CA file on the server"], ["image", "image (default ubuntu:24.04)"], ["warmPool", "warm pool (SandboxWarmPool name)"], ["runtimeClassName", "RuntimeClass (e.g. gvisor)"], ["isolation", "isolation", ["container", "microvm"]], ["volumeSize", "volume size, e.g. 10Gi (enables stop/start)"]]],
- cubesandbox: ["CubeSandbox", "", [["apiURL", "CubeAPI URL", null, true], ["templateId", "template id"], ["sandboxURL", "CubeProxy URL (header routing)"], ["domain", "sandbox domain (default: CubeAPI's)"], ["proxyScheme", "proxy scheme", ["https", "http"]]]],
- "aws-agentcore": ["AWS AgentCore Code Interpreter", "", [["region", "AWS region", null, true], ["codeInterpreter", "code interpreter (default aws.codeinterpreter.v1)"]]],
- "azure-sandboxes": ["Azure Container Apps Sandboxes", "", [["endpoint", "data-plane endpoint", null, true], ["subscriptionId", "subscription ID", null, true], ["resourceGroup", "resource group", null, true], ["sandboxGroup", "sandbox group", null, true], ["diskImage", "public disk image"], ["egress", "egress", ["none", "public"]]]],
- "azure-sessions": ["Azure dynamic sessions (Python)", "", [["endpoint", "pool management endpoint", null, true], ["outputLimit", "output limit (default 1048576)"]]],
- "gcp-sandboxes": ["Google Cloud Agent Platform sandboxes", "", [["project", "project ID", null, true], ["location", "location", null, true], ["reasoningEngine", "engine ID", null, true], ["template", "shell template ID", null, true]]],
- northflank: ["Northflank", "", [["projectId", "project ID", null, true], ["teamId", "team ID (organization tokens)"], ["image", "image (default ubuntu:24.04)"], ["plan", "plan (default nf-compute-200)"]]],
};
function pickKind() {
const [, trust, fields] = kinds[$("#pk-kind").value], t = $("#pk-trust");
@@ -81,10 +70,10 @@ const views = {
return `Usage ${esc(u.note)}
${table(["Pool", "Sandboxes", "Run hours", "vCPU hours", "Est. cost"], u.lines.map((l) => [esc(l.key), l.sandboxes, (l.runSeconds / 3600).toFixed(2), (l.cpuSeconds / 3600).toFixed(2), l.estimatedCost.toFixed(4)]))}`;
},
async audit() {
- const ev = await call("GET", "/v1/audit-events?limit=200");
+ const ev = await call("GET", "/v1/audit-events?limit=200&order=desc");
const verify = me.operator ? await call("GET", "/v1/admin/audit/verify") : null;
return `Audit ${verify ? `Hash chain: ${verify.ok ? "intact " : `broken at ${verify.brokenAt}: ${esc(verify.problem)} `} (${verify.events} events)
` : ""}
- ${table(["#", "Time", "Actor", "Action", "Resource", "Details"], ev.items.slice().reverse().map((e) => [e.seq, esc(new Date(e.time).toLocaleString()), esc(e.actor), esc(e.action), `${esc(e.resource)}`, `${esc(JSON.stringify(e.details || {}))}`]))}`;
+ ${table(["#", "Time", "Actor", "Action", "Resource", "Details"], ev.items.map((e) => [e.seq, esc(new Date(e.time).toLocaleString()), esc(e.actor), esc(e.action), `${esc(e.resource)}`, `${esc(JSON.stringify(e.details || {}))}`]))}`;
},
async keys() {
const [keys, orgs] = await Promise.all([call("GET", "/v1/api-keys"), me.operator ? call("GET", "/v1/admin/organizations") : null]);
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index fdc172b..851076d 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -44,9 +44,46 @@ var (
Reconciled = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "differently_reconcile_actions_total", Help: "Corrections made by the reconciler.",
}, []string{"action", "pool"})
+ // PoolSlots is each pool's slot use: held, released (paused sandboxes
+ // that gave their slot back), waiting_resume and limit.
+ PoolSlots = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "differently_pool_slots", Help: "Pool slots by state: held, released, waiting_resume, limit.",
+ }, []string{"pool", "state"})
+ // Overcommitted is how many slots a pool holds beyond its limit (a
+ // backend woke sandboxes on its own, for example through its gateway).
+ Overcommitted = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Name: "differently_pool_overcommitted", Help: "Slots held beyond the pool's maxConcurrency.",
+ }, []string{"pool"})
+ // ResumesQueued counts resumes that had to wait for a slot.
+ ResumesQueued = prometheus.NewCounterVec(prometheus.CounterOpts{
+ Name: "differently_resumes_queued_total", Help: "Resumes that waited for a free slot.",
+ }, []string{"pool"})
+ // ResumeWaitSeconds measures how long queued resumes waited.
+ ResumeWaitSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "differently_resume_wait_seconds", Help: "Time a queued resume waited for a slot.",
+ Buckets: []float64{1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600},
+ }, []string{"pool"})
+ // PauseSeconds measures how long the backend took to pause: on a
+ // checkpointing pool, the time until the paused sandbox frees its slot
+ // (internal/capsim's PauseTime). Only pauses that finish count: one that
+ // failed or was called off freed nothing.
+ PauseSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Name: "differently_pause_seconds", Help: "Time a finished pause took on its backend.",
+ Buckets: []float64{1, 5, 15, 30, 45, 60, 90, 120, 180, 300, 600, 900},
+ }, []string{"pool"})
+ // Preemptions counts sandboxes paused to make room on a full pool.
+ Preemptions = prometheus.NewCounterVec(prometheus.CounterOpts{
+ Name: "differently_preemptions_total", Help: "Sandboxes paused to make room for waiting work.",
+ }, []string{"pool", "mode"})
+ // LifecycleActions counts the scheduler's own pauses and deletions:
+ // preempted, max_run, max_paused.
+ LifecycleActions = prometheus.NewCounterVec(prometheus.CounterOpts{
+ Name: "differently_lifecycle_actions_total", Help: "Pauses and deletions by lifecycle limit or preemption.",
+ }, []string{"action", "pool"})
)
func init() {
Registry.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
- Registry.MustRegister(Transitions, Placements, ProvisionSeconds, QueueSeconds, Requests, RequestSeconds, Reconciled, AuditFailures)
+ Registry.MustRegister(Transitions, Placements, ProvisionSeconds, QueueSeconds, Requests, RequestSeconds, Reconciled, AuditFailures,
+ PoolSlots, Overcommitted, ResumesQueued, ResumeWaitSeconds, PauseSeconds, Preemptions, LifecycleActions)
}
diff --git a/internal/pools/experimental_test.go b/internal/pools/experimental_test.go
new file mode 100644
index 0000000..e04fccc
--- /dev/null
+++ b/internal/pools/experimental_test.go
@@ -0,0 +1,29 @@
+//go:build !experimental_adapters
+
+package pools
+
+import (
+ "io"
+ "log/slog"
+ "strings"
+ "testing"
+
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// A pool stored by a build that had an experimental adapter stays registered
+// in a build without it: the server starts, the pool is unavailable, and the
+// reason names the build tag.
+func TestStoredPoolOfExperimentalKindNamesTheBuildTag(t *testing.T) {
+ m := NewManager(Deps{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
+ m.Sync([]api.Pool{{Name: "burst", Adapter: "e2b", Trust: api.TrustExternal, Enabled: true}})
+ _, err := m.Adapter("burst")
+ if !api.IsCode(err, api.CodeProviderUnavailable) || !strings.Contains(err.Error(), "-tags experimental_adapters") {
+ t.Fatalf("want provider_unavailable naming the build tag, got %v", err)
+ }
+ pool := api.Pool{Name: "burst"}
+ m.Decorate(&pool, 0)
+ if pool.Health == nil || pool.Health.Status != "unavailable" || !strings.Contains(pool.Health.Message, "experimental_adapters") {
+ t.Fatalf("pool health should say why it cannot run: %+v", pool.Health)
+ }
+}
diff --git a/internal/pools/pools.go b/internal/pools/pools.go
index f03ef74..4582574 100644
--- a/internal/pools/pools.go
+++ b/internal/pools/pools.go
@@ -6,12 +6,14 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
- "fmt"
+ "errors"
+ "io/fs"
"log/slog"
"reflect"
"sync"
"time"
+ "github.com/different-ai/differently/internal/config"
"github.com/different-ai/differently/internal/policy"
"github.com/different-ai/differently/internal/store"
"github.com/different-ai/differently/pkg/adapter"
@@ -103,7 +105,9 @@ func (m *Manager) Sync(pools []api.Pool) {
for _, p := range pools {
seen[p.Name] = true
old, ok := m.entries[p.Name]
- if ok && old.adapter != nil && sameBuild(old.pool, p) && old.credFP == fps[p.Name] {
+ if ok && old.adapter != nil && sameBuild(old.pool, p) && (old.credFP == fps[p.Name] || fps[p.Name] == fpUnreadable) {
+ // An unreadable credential is not a changed one: the adapter that
+ // works keeps the value it was built with until a read succeeds.
old.pool = p
continue
}
@@ -113,7 +117,9 @@ func (m *Manager) Sync(pools []api.Pool) {
}
e := &entry{pool: p, credFP: fps[p.Name]}
if f := factoryFor(p.Adapter); f == nil {
- e.buildErr = fmt.Errorf("adapter kind %q is not available in this build", p.Adapter)
+ // A pool stored by a build that had the kind (an experimental
+ // adapter, say) stays registered and reports why it cannot run.
+ e.buildErr = errors.New(config.UnavailableAdapter(p.Adapter))
} else if a, err := f(p, m.deps); err != nil {
e.buildErr = err
} else {
@@ -154,6 +160,15 @@ func (m *Manager) Recheck() {
m.Sync(list)
}
+// Fingerprints for a credential that could not be resolved. Gone (a deleted
+// secret or file) takes the pool's adapter down, as a removed credential
+// should. Unreadable (the store or the file could not be read just now) says
+// nothing about the value, so it must not tear down a working adapter.
+const (
+ fpGone = "unresolved"
+ fpUnreadable = "unreadable"
+)
+
// fingerprint identifies a pool's current credential value without keeping
// it. Without a resolver, only a changed reference counts as a change.
func (m *Manager) fingerprint(p api.Pool) string {
@@ -161,11 +176,15 @@ func (m *Manager) fingerprint(p api.Pool) string {
return ""
}
v, err := m.deps.Resolve(p.CredentialRef)
- if err != nil {
- return "unresolved"
+ switch {
+ case err == nil:
+ sum := sha256.Sum256([]byte(v))
+ return hex.EncodeToString(sum[:8])
+ case api.IsCode(err, api.CodeNotFound) || errors.Is(err, fs.ErrNotExist):
+ return fpGone
}
- sum := sha256.Sum256([]byte(v))
- return hex.EncodeToString(sum[:8])
+ m.deps.Logger.Warn("pool credential could not be read; keeping the adapter as built", "pool", p.Name, "err", err)
+ return fpUnreadable
}
func sameBuild(a, b api.Pool) bool {
diff --git a/internal/pools/pools_test.go b/internal/pools/pools_test.go
new file mode 100644
index 0000000..cba8aea
--- /dev/null
+++ b/internal/pools/pools_test.go
@@ -0,0 +1,55 @@
+package pools
+
+import (
+ "errors"
+ "io"
+ "log/slog"
+ "testing"
+
+ "github.com/different-ai/differently/internal/adapters/fake"
+ "github.com/different-ai/differently/internal/store"
+ "github.com/different-ai/differently/pkg/adapter"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// A credential that could not be read just now (a database blip, an I/O
+// error) is not a changed credential: the pool keeps the adapter that works.
+// A changed value rebuilds it, and a deleted one takes the pool down.
+func TestSyncKeepsAdapterWhenCredentialUnreadable(t *testing.T) {
+ value, readErr := "key-1", error(nil)
+ resolve := func(string) (string, error) { return value, readErr }
+ builds := 0
+ Register("pools-test", func(p api.Pool, deps Deps) (adapter.ExecutionAdapter, error) {
+ if _, err := deps.Resolve(p.CredentialRef); err != nil {
+ return nil, err
+ }
+ builds++
+ return fake.New(fake.Options{Trust: api.TrustExternal}), nil
+ })
+ m := NewManager(Deps{Resolve: resolve, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
+ list := []api.Pool{{Name: "provider", Adapter: "pools-test", Trust: api.TrustExternal, Enabled: true, CredentialRef: "secret:provider-key"}}
+
+ m.Sync(list)
+ first, err := m.Adapter("provider")
+ if err != nil || builds != 1 {
+ t.Fatalf("first build: %v (builds %d)", err, builds)
+ }
+
+ readErr = errors.New("connection reset by peer")
+ m.Sync(list)
+ if got, err := m.Adapter("provider"); err != nil || got != first || builds != 1 {
+ t.Fatalf("an unreadable credential replaced a working adapter: %v (builds %d)", err, builds)
+ }
+
+ value, readErr = "key-2", nil
+ m.Sync(list)
+ if got, err := m.Adapter("provider"); err != nil || got == first || builds != 2 {
+ t.Fatalf("a rotated credential did not rebuild the adapter: %v (builds %d)", err, builds)
+ }
+
+ readErr = store.NotFound("secret", "provider-key")
+ m.Sync(list)
+ if _, err := m.Adapter("provider"); err == nil {
+ t.Fatal("a deleted credential left the pool's adapter in place")
+ }
+}
diff --git a/internal/scheduler/capacity.go b/internal/scheduler/capacity.go
new file mode 100644
index 0000000..33553fc
--- /dev/null
+++ b/internal/scheduler/capacity.go
@@ -0,0 +1,508 @@
+package scheduler
+
+// Capacity: who holds a pool's slots, and how slots move between sandboxes.
+//
+// - A pool's slots are its maxConcurrency. Provisioning, running, stopped
+// and terminating sandboxes hold one. A paused sandbox holds one only on
+// a pool whose pause keeps it on the host; on a pool that checkpoints on
+// pause (lifecycle.pause-checkpoint) the pause gives the slot back
+// (SlotReleased).
+// - Resuming takes a slot again, atomically with the concurrency check. On
+// a full pool the resume waits in a queue (ResumeRequestedAt), pinned to
+// the pool that holds its checkpoint, and is served in fair order as
+// slots free up.
+// - A pool with a preemption policy makes room for waiting work (queued
+// resumes, and queued creates waiting for that pool) by pausing running
+// sandboxes, least recently active first and never one that is not
+// preemptible. Mode idle picks only sandboxes idle for idleSeconds (60 by
+// default), ahead of their idle timeout; mode lru may pick a busy one that
+// ran at least minRunSeconds. The victim keeps its memory and wakes on its
+// next call, queued like any other resume. internal/capsim measures the
+// trade-off: idle mode shortens the queue without interrupting work.
+// - maxRunSeconds pauses a sandbox that has run that long without a break;
+// maxPausedSeconds deletes one left paused or stopped that long.
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/different-ai/differently/internal/metrics"
+ "github.com/different-ai/differently/internal/store"
+ "github.com/different-ai/differently/pkg/adapter"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// CodeInsufficientCapacity is the code of a pool at its concurrency limit.
+const CodeInsufficientCapacity = "insufficient_capacity"
+
+// Preemption defaults.
+const (
+ defaultMinRunSeconds = 600
+ defaultIdleSeconds = 60
+)
+
+// LifecycleTimeout bounds one stop, start, pause or resume on a backend.
+const LifecycleTimeout = 15 * time.Minute
+
+// pauseWatch is how often a pause in progress checks that it is still wanted.
+const pauseWatch = 500 * time.Millisecond
+
+// MarkPaused records a pause (or stop) on r: when it happened and, for a pause
+// on a pool that checkpoints, that the sandbox gave its slot back.
+func MarkPaused(r *store.SandboxRecord, now time.Time, releasesSlot bool) {
+ stopClock(r, now)
+ r.PausedAt = &now
+ r.SlotReleased = r.Status == api.StatusPaused && releasesSlot
+}
+
+// MarkRunning records a resume or start on r.
+func MarkRunning(r *store.SandboxRecord, now time.Time) {
+ stopClock(r, now) // a pause that was called off never stopped it
+ r.Status, r.UpdatedAt = api.StatusRunning, now
+ r.RunningSince, r.LastActivityAt = &now, &now
+ r.PausedAt, r.ResumeRequestedAt, r.SlotReleased, r.IdledAt = nil, nil, false, nil
+}
+
+// Pause pauses a running sandbox on its backend, marking it pausing first so
+// that nothing new starts on it. A Fast Sandbox VM runs on for minutes after
+// its memory dump while the checkpoint uploads, and what it does then is gone
+// when the checkpoint is restored. mark sets the caller's own fields on the
+// pausing record, and the caller records the outcome. A failed pause puts the
+// sandbox back to running; one called off by a resume or a delete stops being
+// waited for and answers conflict. A finished pause's length goes to
+// differently_pause_seconds.
+func (s *Scheduler) Pause(ctx context.Context, pz adapter.Pauser, rec store.SandboxRecord, mark func(*store.SandboxRecord)) error {
+ if _, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ if r.Status != api.StatusRunning {
+ return api.Errorf(api.CodeInvalidState, "cannot pause a %s sandbox", r.Status).With("status", string(r.Status))
+ }
+ r.Status, r.UpdatedAt = api.StatusPausing, s.Now()
+ if mark != nil {
+ mark(r)
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+ metrics.Transitions.WithLabelValues(string(api.StatusPausing), rec.Pool).Inc()
+ waiting, stop := context.WithCancel(ctx)
+ defer stop()
+ go func() {
+ t := time.NewTicker(pauseWatch)
+ defer t.Stop()
+ for {
+ select {
+ case <-waiting.Done():
+ return
+ case <-t.C:
+ }
+ if cur, err := s.Store.GetSandbox(waiting, rec.ID); err == nil && cur.Status != api.StatusPausing {
+ stop()
+ return
+ }
+ }
+ }()
+ started := time.Now()
+ err := pz.Pause(waiting, rec.BackendRef)
+ if err == nil {
+ metrics.PauseSeconds.WithLabelValues(rec.Pool).Observe(time.Since(started).Seconds())
+ return nil
+ }
+ ctx = context.WithoutCancel(ctx)
+ switch cur, gerr := s.Store.GetSandbox(ctx, rec.ID); {
+ case gerr == nil && cur.Status == api.StatusPaused:
+ return nil // the reconciler saw the backend finish first
+ case gerr == nil && cur.Status != api.StatusPausing:
+ return api.Errorf(api.CodeConflict, "sandbox changed to %s during the pause", cur.Status)
+ }
+ s.abortPause(ctx, rec.ID)
+ return err
+}
+
+// abortPause puts a sandbox whose pause did not happen back to running.
+func (s *Scheduler) abortPause(ctx context.Context, sid string) {
+ _, _ = s.Store.UpdateSandbox(ctx, sid, func(r *store.SandboxRecord) error {
+ if r.Status == api.StatusPausing {
+ r.Status, r.UpdatedAt = api.StatusRunning, s.Now()
+ r.IdledAt, r.ResumeRequestedAt, r.StatusReason = nil, nil, ""
+ }
+ return nil
+ })
+}
+
+// SlotFreed lets waiting work at a freed slot right away instead of after its
+// retry delay.
+func (s *Scheduler) SlotFreed(ctx context.Context) { s.nudge(ctx) }
+
+// ReleasesSlot reports whether a pause on pool frees the sandbox's slot.
+func (s *Scheduler) ReleasesSlot(ctx context.Context, pool string) bool {
+ return s.Pools.Supports(ctx, pool, adapter.FeaturePauseCheckpoint)
+}
+
+// ReclaimSlot gives a paused sandbox its slot back before it resumes. A full
+// pool answers insufficient_capacity.
+func (s *Scheduler) ReclaimSlot(ctx context.Context, rec store.SandboxRecord) (store.SandboxRecord, error) {
+ if !rec.SlotReleased {
+ return rec, nil
+ }
+ p, err := s.Store.GetPool(ctx, rec.Pool)
+ if err != nil {
+ return rec, err
+ }
+ return s.Store.ReclaimSlot(ctx, rec.ID, p.MaxConcurrency, s.Now())
+}
+
+// QueueResume puts a paused sandbox in the resume queue of its pool. A sandbox
+// still pausing joins it now and runs once its pause has finished.
+func (s *Scheduler) QueueResume(ctx context.Context, sid string) (store.SandboxRecord, error) {
+ now := s.Now()
+ out, err := s.Store.UpdateSandbox(ctx, sid, func(r *store.SandboxRecord) error {
+ if r.Status != api.StatusPaused && r.Status != api.StatusPausing {
+ return api.Errorf(api.CodeInvalidState, "sandbox %s is %s, not paused", sid, r.Status)
+ }
+ if r.ResumeRequestedAt == nil {
+ r.ResumeRequestedAt = &now
+ metrics.ResumesQueued.WithLabelValues(r.Pool).Inc()
+ }
+ r.StatusReason = fmt.Sprintf("waiting for a free slot on pool %s to resume", r.Pool)
+ if r.Status == api.StatusPausing {
+ r.StatusReason = "resumes when its pause finishes"
+ }
+ return nil
+ })
+ s.Wake()
+ return out, err
+}
+
+// resumeQueued resumes waiting sandboxes, in fair order, as slots free up.
+func (s *Scheduler) resumeQueued(ctx context.Context) map[string]int {
+ waiting, err := s.Store.ListSandboxes(ctx, store.SandboxFilter{Statuses: []api.SandboxStatus{api.StatusPaused}})
+ if err != nil {
+ return nil
+ }
+ var queue []store.SandboxRecord
+ for _, r := range waiting {
+ if r.ResumeRequestedAt != nil && !s.isInflight(r.ID) {
+ queue = append(queue, r)
+ }
+ }
+ sort.SliceStable(queue, func(i, j int) bool {
+ if queue[i].Priority != queue[j].Priority {
+ return queue[i].Priority > queue[j].Priority
+ }
+ return queue[i].ResumeRequestedAt.Before(*queue[j].ResumeRequestedAt)
+ })
+ demand := map[string]int{}
+ full := map[string]bool{}
+ for _, rec := range queue {
+ if full[rec.Pool] {
+ demand[rec.Pool]++
+ continue
+ }
+ out, err := s.ReclaimSlot(ctx, rec)
+ if api.IsCode(err, CodeInsufficientCapacity) {
+ full[rec.Pool] = true
+ demand[rec.Pool]++
+ continue
+ }
+ if err != nil || !s.claim(rec.ID) {
+ continue
+ }
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ defer s.release(rec.ID)
+ s.resume(ctx, out)
+ }()
+ }
+ return demand
+}
+
+// resume brings a queued sandbox back once it holds a slot.
+func (s *Scheduler) resume(ctx context.Context, rec store.SandboxRecord) {
+ a, err := s.Pools.Adapter(rec.Pool)
+ if err == nil {
+ pz, ok := a.(adapter.Pauser)
+ if !ok {
+ err = fmt.Errorf("pool %s cannot resume", rec.Pool)
+ } else {
+ err = pz.Resume(ctx, rec.BackendRef)
+ }
+ }
+ now := s.Now()
+ if err != nil {
+ // Give the slot back and keep the request queued for the next tick.
+ s.Log.Warn("queued resume failed", "sandbox", rec.ID, "pool", rec.Pool, "err", err)
+ _, _ = s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ if r.Status == api.StatusPaused {
+ r.SlotReleased = s.ReleasesSlot(ctx, r.Pool)
+ }
+ return nil
+ })
+ return
+ }
+ waited := 0.0
+ if rec.ResumeRequestedAt != nil {
+ waited = now.Sub(*rec.ResumeRequestedAt).Seconds()
+ }
+ out, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ if r.Status != api.StatusPaused {
+ return nil
+ }
+ MarkRunning(r, now)
+ r.StatusReason = ""
+ return nil
+ })
+ if err != nil || out.Status != api.StatusRunning {
+ return
+ }
+ metrics.ResumeWaitSeconds.WithLabelValues(rec.Pool).Observe(waited)
+ metrics.Transitions.WithLabelValues(string(api.StatusRunning), rec.Pool).Inc()
+ s.Audit.Record(ctx, api.AuditEvent{Actor: "scheduler", OrgID: out.OrgID, ProjectID: out.ProjectID, Action: "sandbox.resumed", Resource: out.ID, Details: map[string]string{"pool": out.Pool, "waitedSeconds": fmt.Sprintf("%.0f", waited)}})
+}
+
+// createDemand counts queued creates waiting for a slot on each pool: those
+// whose last placement found the pool eligible but at its concurrency limit
+// and no pool ready.
+func (s *Scheduler) createDemand(ctx context.Context, demand map[string]int) {
+ queued, err := s.Store.ListSandboxes(ctx, store.SandboxFilter{Statuses: []api.SandboxStatus{api.StatusQueued}, Limit: 500})
+ if err != nil {
+ return
+ }
+ for _, r := range queued {
+ if r.Decision == nil {
+ continue
+ }
+ ready, target := false, ""
+ for _, c := range r.Decision.Candidates {
+ if c.Eligible && c.HasCapacity {
+ ready = true
+ break
+ }
+ if c.Eligible && target == "" {
+ for _, why := range c.Reasons {
+ if strings.HasPrefix(why, "at concurrency limit") {
+ target = c.Pool
+ }
+ }
+ }
+ }
+ if !ready && target != "" {
+ demand[target]++
+ }
+ }
+}
+
+// makeRoom preempts running sandboxes on pools with waiting work and a
+// preemption policy, at most one victim per waiting sandbox, counting victims
+// still pausing.
+func (s *Scheduler) makeRoom(ctx context.Context, demand map[string]int) {
+ if len(demand) == 0 {
+ return
+ }
+ now := s.Now()
+ for pool, want := range demand {
+ p, err := s.Store.GetPool(ctx, pool)
+ if err != nil || p.Preemption == nil || p.Preemption.Mode == "" || p.Preemption.Mode == api.PreemptOff || !s.ReleasesSlot(ctx, pool) {
+ continue
+ }
+ s.mu.Lock()
+ want -= s.preempting[pool]
+ s.mu.Unlock()
+ if want <= 0 {
+ continue
+ }
+ running, err := s.Store.ListSandboxes(ctx, store.SandboxFilter{Pool: pool, Statuses: []api.SandboxStatus{api.StatusRunning}})
+ if err != nil {
+ continue
+ }
+ for _, v := range Victims(running, *p.Preemption, now) {
+ if want == 0 {
+ break
+ }
+ if s.isInflight(v.ID) || !s.claim(v.ID) {
+ continue
+ }
+ want--
+ s.mu.Lock()
+ s.preempting[pool]++
+ s.mu.Unlock()
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ defer s.release(v.ID)
+ defer func() {
+ s.mu.Lock()
+ s.preempting[pool]--
+ s.mu.Unlock()
+ }()
+ if s.park(ctx, v, "preempted", fmt.Sprintf("paused to make room on pool %s (preemption %s); the next exec, file, port or keepalive call resumes it", pool, p.Preemption.Mode)) {
+ metrics.Preemptions.WithLabelValues(pool, p.Preemption.Mode).Inc()
+ }
+ }()
+ }
+ }
+}
+
+// Victims orders the running sandboxes a preemption policy may pause, best
+// first: least recently active, then lowest priority, then longest running.
+func Victims(running []store.SandboxRecord, p api.Preemption, now time.Time) []store.SandboxRecord {
+ minRun := time.Duration(p.MinRunSeconds) * time.Second
+ if p.MinRunSeconds == 0 {
+ minRun = defaultMinRunSeconds * time.Second
+ }
+ idleFor := time.Duration(p.IdleSeconds) * time.Second
+ if p.IdleSeconds == 0 {
+ idleFor = defaultIdleSeconds * time.Second
+ }
+ last := func(r store.SandboxRecord) time.Time {
+ if r.LastActivityAt != nil {
+ return *r.LastActivityAt
+ }
+ return r.UpdatedAt
+ }
+ var out []store.SandboxRecord
+ for _, r := range running {
+ switch {
+ case !r.Preemptible || r.BackendRef == "":
+ case p.Mode == api.PreemptLRU && r.RunningSince != nil && now.Sub(*r.RunningSince) < minRun:
+ case p.Mode == api.PreemptIdle && now.Sub(last(r)) < idleFor:
+ default:
+ out = append(out, r)
+ }
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ a, b := out[i], out[j]
+ if !last(a).Equal(last(b)) {
+ return last(a).Before(last(b))
+ }
+ if a.Priority != b.Priority {
+ return a.Priority < b.Priority
+ }
+ return a.RunningSince != nil && (b.RunningSince == nil || a.RunningSince.Before(*b.RunningSince))
+ })
+ return out
+}
+
+// EnforceLifecycle applies maxRunSeconds and maxPausedSeconds.
+func (s *Scheduler) EnforceLifecycle(ctx context.Context) {
+ now := s.Now()
+ recs, err := s.Store.ListSandboxes(ctx, store.SandboxFilter{Statuses: []api.SandboxStatus{api.StatusRunning, api.StatusPaused, api.StatusStopped}})
+ if err != nil {
+ return
+ }
+ for _, rec := range recs {
+ switch {
+ case rec.Status == api.StatusRunning && rec.MaxRunSeconds > 0 && rec.RunningSince != nil &&
+ now.Sub(*rec.RunningSince) >= time.Duration(rec.MaxRunSeconds)*time.Second && !s.recentlyChanged(rec):
+ if !s.claim(rec.ID) {
+ continue
+ }
+ s.park(ctx, rec, "max_run", fmt.Sprintf("paused after running %d s without a break (maxRunSeconds); the next exec, file, port or keepalive call resumes it", rec.MaxRunSeconds))
+ s.release(rec.ID)
+ case rec.Status != api.StatusRunning && rec.MaxPausedSeconds > 0 && rec.PausedAt != nil &&
+ now.Sub(*rec.PausedAt) >= time.Duration(rec.MaxPausedSeconds)*time.Second:
+ out, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ if r.Status == api.StatusRunning || r.Status.Terminal() || r.Status == api.StatusTerminating {
+ return nil
+ }
+ r.Status, r.UpdatedAt = api.StatusTerminating, now
+ r.StatusReason = fmt.Sprintf("deleted after %d s paused (maxPausedSeconds)", rec.MaxPausedSeconds)
+ return nil
+ })
+ if err == nil && out.Status == api.StatusTerminating {
+ metrics.LifecycleActions.WithLabelValues("max_paused", rec.Pool).Inc()
+ s.Audit.Record(ctx, api.AuditEvent{Actor: "scheduler", OrgID: out.OrgID, ProjectID: out.ProjectID, Action: "sandbox.expired", Resource: out.ID, Details: map[string]string{"reason": "maxPausedSeconds"}})
+ }
+ }
+ }
+}
+
+// park pauses (or, where the pool cannot pause, stops) a running sandbox for
+// the scheduler's own reasons and marks it idled, so the next call wakes it.
+// It reports whether the sandbox was parked.
+func (s *Scheduler) park(ctx context.Context, rec store.SandboxRecord, kind, reason string) bool {
+ a, err := s.Pools.Adapter(rec.Pool)
+ if err != nil {
+ return false
+ }
+ to, from := api.StatusPaused, api.StatusPausing
+ switch pz, pausable := a.(adapter.Pauser); {
+ case pausable && s.Pools.Supports(ctx, rec.Pool, adapter.FeaturePauseResume):
+ err = s.Pause(ctx, pz, rec, func(r *store.SandboxRecord) {
+ now := s.Now()
+ r.IdledAt, r.StatusReason = &now, reason
+ })
+ case s.Pools.Supports(ctx, rec.Pool, adapter.FeatureStopStart):
+ l, ok := a.(adapter.Lifecycle)
+ if !ok {
+ return false
+ }
+ to, from = api.StatusStopped, api.StatusRunning
+ err = l.Stop(ctx, rec.BackendRef)
+ default:
+ return false
+ }
+ if err != nil {
+ s.Log.Warn(kind+" pause failed", "sandbox", rec.ID, "pool", rec.Pool, "err", err)
+ return false
+ }
+ now := s.Now()
+ releases := s.ReleasesSlot(ctx, rec.Pool)
+ out, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ if r.Status != from {
+ return api.Errorf(api.CodeConflict, "sandbox changed to %s during the %s pause", r.Status, kind)
+ }
+ r.Status, r.UpdatedAt, r.IdledAt, r.StatusReason = to, now, &now, reason
+ MarkPaused(r, now, releases)
+ return nil
+ })
+ if err != nil {
+ return false
+ }
+ metrics.LifecycleActions.WithLabelValues(kind, rec.Pool).Inc()
+ metrics.Transitions.WithLabelValues(string(to), rec.Pool).Inc()
+ s.Audit.Record(ctx, api.AuditEvent{Actor: "scheduler", OrgID: out.OrgID, ProjectID: out.ProjectID, Action: "sandbox." + kind, Resource: out.ID, Details: map[string]string{"pool": out.Pool, "to": string(to)}})
+ if out.SlotReleased {
+ s.SlotFreed(ctx)
+ }
+ return true
+}
+
+// observeSlots publishes each pool's slot use.
+func (s *Scheduler) observeSlots(ctx context.Context) {
+ poolsList, err := s.Store.ListPools(ctx)
+ if err != nil {
+ return
+ }
+ recs, err := s.Store.ListSandboxes(ctx, store.SandboxFilter{Statuses: []api.SandboxStatus{api.StatusProvisioning, api.StatusRunning, api.StatusPausing, api.StatusPaused, api.StatusStopped, api.StatusTerminating}})
+ if err != nil {
+ return
+ }
+ held, released, waiting := map[string]int{}, map[string]int{}, map[string]int{}
+ for _, r := range recs {
+ switch {
+ case store.HoldsPoolSlot(r):
+ held[r.Pool]++
+ case r.Status == api.StatusPaused:
+ released[r.Pool]++
+ }
+ if r.ResumeRequestedAt != nil {
+ waiting[r.Pool]++
+ }
+ }
+ for _, p := range poolsList {
+ metrics.PoolSlots.WithLabelValues(p.Name, "held").Set(float64(held[p.Name]))
+ metrics.PoolSlots.WithLabelValues(p.Name, "released").Set(float64(released[p.Name]))
+ metrics.PoolSlots.WithLabelValues(p.Name, "waiting_resume").Set(float64(waiting[p.Name]))
+ metrics.PoolSlots.WithLabelValues(p.Name, "limit").Set(float64(p.MaxConcurrency))
+ if p.MaxConcurrency > 0 && held[p.Name] > p.MaxConcurrency {
+ metrics.Overcommitted.WithLabelValues(p.Name).Set(float64(held[p.Name] - p.MaxConcurrency))
+ } else {
+ metrics.Overcommitted.WithLabelValues(p.Name).Set(0)
+ }
+ }
+}
diff --git a/internal/scheduler/capacity_test.go b/internal/scheduler/capacity_test.go
new file mode 100644
index 0000000..c8a403b
--- /dev/null
+++ b/internal/scheduler/capacity_test.go
@@ -0,0 +1,51 @@
+package scheduler
+
+import (
+ "testing"
+ "time"
+
+ "github.com/different-ai/differently/internal/store"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+func TestVictimsOrder(t *testing.T) {
+ now := time.Now()
+ ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
+ rec := func(id string, active, running time.Duration, prio int, preemptible bool) store.SandboxRecord {
+ return store.SandboxRecord{BackendRef: "ref-" + id, RunningSince: ago(running), Sandbox: api.Sandbox{ID: id, LastActivityAt: ago(active), Priority: prio, Preemptible: preemptible}}
+ }
+ running := []store.SandboxRecord{
+ rec("busy", time.Second, time.Hour, 0, true),
+ rec("young", time.Hour, time.Minute, 0, true), // under minRunSeconds
+ rec("guarded", 3*time.Hour, time.Hour, 0, false), // not preemptible
+ rec("idle-high", 2*time.Hour, time.Hour, 5, true),
+ rec("idle-low", 2*time.Hour, time.Hour, 0, true),
+ rec("oldest", 4*time.Hour, 5*time.Hour, 9, true),
+ }
+ ids := func(rs []store.SandboxRecord) (out []string) {
+ for _, r := range rs {
+ out = append(out, r.ID)
+ }
+ return out
+ }
+ lru := ids(Victims(running, api.Preemption{Mode: api.PreemptLRU}, now))
+ if want := []string{"oldest", "idle-low", "idle-high", "busy"}; !equal(lru, want) {
+ t.Fatalf("lru victims %v, want %v", lru, want)
+ }
+ idle := ids(Victims(running, api.Preemption{Mode: api.PreemptIdle, IdleSeconds: 3600}, now))
+ if want := []string{"oldest", "idle-low", "idle-high", "young"}; !equal(idle, want) {
+ t.Fatalf("idle victims %v, want %v", idle, want)
+ }
+}
+
+func equal(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
index 1a10e76..f8d0f34 100644
--- a/internal/scheduler/scheduler.go
+++ b/internal/scheduler/scheduler.go
@@ -41,7 +41,9 @@ const RetryDelay = 2 * time.Second
// Scheduler runs dispatch, provisioning, termination and reconciliation.
type Scheduler struct {
- sweeping atomic.Bool // an idle sweep is running
+ sweeping atomic.Bool // an idle sweep is running
+ refreshing atomic.Bool // a pool probe pass is running
+ reconciling atomic.Bool // a reconcile pass is running
Store store.Store
Pools *pools.Manager
@@ -53,11 +55,15 @@ type Scheduler struct {
// ReconcileGrace is how long the reconciler leaves a sandbox alone after
// it changed, so it never races a lifecycle operation.
ReconcileGrace time.Duration
+ // RefreshInterval is how often pools are probed; zero means 5 seconds.
+ RefreshInterval time.Duration
- wake chan struct{}
- mu sync.Mutex
- inflight map[string]bool
- wg sync.WaitGroup
+ wake chan struct{}
+ mu sync.Mutex
+ // preempting counts victims still pausing, per pool.
+ preempting map[string]int
+ inflight map[string]bool
+ wg sync.WaitGroup
}
// New returns a scheduler.
@@ -65,7 +71,7 @@ func New(st store.Store, pm *pools.Manager, cfg config.Config, installation stri
return &Scheduler{
Store: st, Pools: pm, Config: cfg, Installation: installation, Audit: rec, Log: log,
Now: func() time.Time { return time.Now().UTC() }, ReconcileGrace: 30 * time.Second,
- wake: make(chan struct{}, 1), inflight: map[string]bool{},
+ wake: make(chan struct{}, 1), inflight: map[string]bool{}, preempting: map[string]int{},
}
}
@@ -78,32 +84,49 @@ func (s *Scheduler) Wake() {
}
// Run loops until ctx ends.
+//
+// Only dispatch runs on the loop itself. Pool probes and the reconciler call
+// backends, and one that does not answer holds its caller until a timeout (5 s
+// per probe, 30 s per list), so they run beside the loop: an unreachable pool
+// must not slow placement on the others.
func (s *Scheduler) Run(ctx context.Context) {
+ every := s.RefreshInterval
+ if every <= 0 {
+ every = 5 * time.Second
+ }
tick := time.NewTicker(time.Duration(s.Config.Scheduler.IntervalMs) * time.Millisecond)
- refresh := time.NewTicker(5 * time.Second)
+ refresh := time.NewTicker(every)
reconcile := time.NewTicker(time.Duration(s.Config.Scheduler.ReconcileIntervalSecs) * time.Second)
defer tick.Stop()
defer refresh.Stop()
defer reconcile.Stop()
- s.Pools.Refresh(ctx)
+ s.Pools.Refresh(ctx) // the first tick needs a view of every pool
for {
select {
case <-ctx.Done():
s.wg.Wait()
return
case <-refresh.C:
- s.Pools.Refresh(ctx)
+ if s.refreshing.CompareAndSwap(false, true) {
+ go func() {
+ defer s.refreshing.Store(false)
+ s.Pools.Refresh(ctx)
+ }()
+ }
continue
case <-reconcile.C:
- if s.leader(ctx) {
- s.Reconcile(ctx)
- if s.sweeping.CompareAndSwap(false, true) {
- // Stops can take seconds each: never hold up the loop.
- go func() {
+ if s.leader(ctx) && s.reconciling.CompareAndSwap(false, true) {
+ go func() {
+ s.Reconcile(ctx)
+ s.reconciling.Store(false)
+ // Stops can take seconds each, so the sweep does not hold
+ // up the next reconcile pass either.
+ if s.sweeping.CompareAndSwap(false, true) {
defer s.sweeping.Store(false)
s.SweepIdle(ctx)
- }()
- }
+ s.EnforceLifecycle(ctx)
+ }
+ }()
}
continue
case <-tick.C:
@@ -127,49 +150,70 @@ func (s *Scheduler) leader(ctx context.Context) bool {
func (s *Scheduler) Tick(ctx context.Context) {
s.dispatch(ctx)
s.startProvisioning(ctx)
+ demand := s.resumeQueued(ctx)
+ if demand == nil {
+ demand = map[string]int{}
+ }
+ s.createDemand(ctx, demand)
+ s.makeRoom(ctx, demand)
s.terminate(ctx)
s.expire(ctx)
+ s.observeSlots(ctx)
}
// Drain waits for in-flight provisioning and termination.
func (s *Scheduler) Drain() { s.wg.Wait() }
// Policies returns the placement policies that apply to a tenant: global,
-// then the organization's and the project's when they exist.
-func (s *Scheduler) Policies(ctx context.Context, orgID, projectID string) []api.PlacementPolicy {
- return s.policies(ctx, orgID, projectID)
-}
-
-func (s *Scheduler) policies(ctx context.Context, orgID, projectID string) []api.PlacementPolicy {
+// then the organization's and the project's when they exist. A policy that
+// cannot be read is an error, never an absent policy: placing with a scope
+// left out could run work where that scope forbids it.
+func (s *Scheduler) Policies(ctx context.Context, orgID, projectID string) ([]api.PlacementPolicy, error) {
global := s.Config.Policy
- if p, ok, _ := s.Store.GetPolicy(ctx, "global"); ok {
+ if p, ok, err := s.Store.GetPolicy(ctx, "global"); err != nil {
+ return nil, fmt.Errorf("read placement policy global: %w", err)
+ } else if ok {
global = p
}
out := []api.PlacementPolicy{global}
for _, scope := range []string{"org:" + orgID, "project:" + projectID} {
- if p, ok, _ := s.Store.GetPolicy(ctx, scope); ok {
+ p, ok, err := s.Store.GetPolicy(ctx, scope)
+ if err != nil {
+ return nil, fmt.Errorf("read placement policy %s: %w", scope, err)
+ }
+ if ok {
out = append(out, p)
}
}
- return out
+ return out, nil
}
-// PlacementInput assembles the policy input for a sandbox.
-func (s *Scheduler) PlacementInput(ctx context.Context, rec store.SandboxRecord, active map[string]int) policy.Input {
+// PlacementInput assembles the policy input for a sandbox. It fails rather
+// than decide on a partial read of the policies or of the snapshot that pins
+// the sandbox to a pool.
+func (s *Scheduler) PlacementInput(ctx context.Context, rec store.SandboxRecord, active map[string]int) (policy.Input, error) {
+ policies, err := s.Policies(ctx, rec.OrgID, rec.ProjectID)
+ if err != nil {
+ return policy.Input{}, err
+ }
class, _ := s.Config.Classification(rec.Classification)
in := policy.Input{
- Sandbox: rec.Sandbox, Policies: s.policies(ctx, rec.OrgID, rec.ProjectID), Classification: class,
+ Sandbox: rec.Sandbox, Policies: policies, Classification: class,
Pools: s.Pools.Views(active), ExcludedPools: rec.ExcludedPools, Now: s.Now(),
}
if rec.FromSnapshot != "" {
- if snap, err := s.Store.GetSnapshot(ctx, rec.FromSnapshot); err == nil {
+ snap, err := s.Store.GetSnapshot(ctx, rec.FromSnapshot)
+ switch {
+ case err == nil:
in.PinnedPool = snap.Pool
+ case !api.IsCode(err, api.CodeNotFound):
+ return policy.Input{}, fmt.Errorf("read snapshot %s: %w", rec.FromSnapshot, err)
}
}
if t, ok := s.Config.Template(rec.Template); ok && rec.Template != "" {
in.Template = &t
}
- return in
+ return in, nil
}
func (s *Scheduler) dispatch(ctx context.Context) {
@@ -199,7 +243,13 @@ func (s *Scheduler) dispatch(ctx context.Context) {
if err != nil {
continue
}
- in := s.PlacementInput(ctx, rec, active)
+ in, err := s.PlacementInput(ctx, rec, active)
+ if err != nil {
+ // Fail closed: leave it queued until the policies can be read.
+ s.Log.Warn("placement input unavailable; sandbox stays queued", "sandbox", rec.ID, "err", err)
+ s.wait(ctx, rec.ID, "waiting: placement policy could not be read", nil)
+ continue
+ }
res := policy.Decide(in)
if (res.Err != nil || len(res.Ranked) == 0) && len(in.ExcludedPools) > 0 {
// Every other pool is out: retry the pools that failed before.
@@ -287,7 +337,14 @@ func (s *Scheduler) wait(ctx context.Context, sid, reason string, dec *api.Place
}
func (s *Scheduler) fail(ctx context.Context, sid, reason string, dec *api.PlacementDecision) {
+ // The caller decided on a record it read earlier. A sandbox that ended or
+ // was deleted since keeps that outcome: the terminator still has to remove
+ // its backend instance, and "failed" would hide the delete.
+ failed := false
rec, err := s.Store.UpdateSandbox(ctx, sid, func(r *store.SandboxRecord) error {
+ if failed = !r.Status.Terminal() && r.Status != api.StatusTerminating; !failed {
+ return nil
+ }
now := s.Now()
stopClock(r, now)
r.Status, r.StatusReason, r.EndedAt, r.UpdatedAt = api.StatusFailed, reason, &now, now
@@ -296,7 +353,7 @@ func (s *Scheduler) fail(ctx context.Context, sid, reason string, dec *api.Place
}
return nil
})
- if err == nil {
+ if err == nil && failed {
metrics.Transitions.WithLabelValues(string(api.StatusFailed), rec.Pool).Inc()
s.Audit.Record(ctx, api.AuditEvent{Actor: "scheduler", OrgID: rec.OrgID, ProjectID: rec.ProjectID, Action: "sandbox.failed", Resource: sid, Outcome: "failure", Details: map[string]string{"reason": reason}})
}
@@ -483,10 +540,20 @@ func (s *Scheduler) expire(ctx context.Context) {
if rec.ExpiresAt == nil || rec.ExpiresAt.After(now) {
continue
}
- _, _ = s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ // Check again under the row lock: the TTL may have been extended, or
+ // the sandbox deleted or failed, since the list was read.
+ expired := false
+ _, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ live := r.Status == api.StatusRunning || r.Status == api.StatusPaused || r.Status == api.StatusStopped
+ if expired = live && r.ExpiresAt != nil && !r.ExpiresAt.After(now); !expired {
+ return nil
+ }
r.Status, r.StatusReason, r.UpdatedAt = api.StatusTerminating, "ttl expired", now
return nil
})
+ if err != nil || !expired {
+ continue
+ }
s.Audit.Record(ctx, api.AuditEvent{Actor: "scheduler", OrgID: rec.OrgID, ProjectID: rec.ProjectID, Action: "sandbox.expired", Resource: rec.ID})
}
}
@@ -533,16 +600,22 @@ func (s *Scheduler) idle(ctx context.Context, rec store.SandboxRecord) {
if err != nil {
return
}
- to, action := api.StatusPaused, "pause"
+ to, from, action := api.StatusPaused, api.StatusPausing, "pause"
+ reason := func() string {
+ return fmt.Sprintf("%s after %d s idle; the next exec, file, port or keepalive call wakes it", action, rec.IdleTimeoutSeconds)
+ }
switch pz, pausable := a.(adapter.Pauser); {
case pausable && s.Pools.Supports(ctx, rec.Pool, adapter.FeaturePauseResume):
- err = pz.Pause(ctx, rec.BackendRef)
+ err = s.Pause(ctx, pz, rec, func(r *store.SandboxRecord) {
+ now := s.Now()
+ r.IdledAt, r.StatusReason = &now, reason()
+ })
case s.Pools.Supports(ctx, rec.Pool, adapter.FeatureStopStart):
l, ok := a.(adapter.Lifecycle)
if !ok {
return
}
- to, action = api.StatusStopped, "stop"
+ to, from, action = api.StatusStopped, api.StatusRunning, "stop"
err = l.Stop(ctx, rec.BackendRef)
default:
return
@@ -552,19 +625,23 @@ func (s *Scheduler) idle(ctx context.Context, rec store.SandboxRecord) {
return
}
now := s.Now()
+ releases := s.ReleasesSlot(ctx, rec.Pool)
out, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
- if r.Status != api.StatusRunning {
+ if r.Status != from {
return api.Errorf(api.CodeConflict, "sandbox changed to %s during the idle %s", r.Status, action)
}
- stopClock(r, now)
r.Status, r.UpdatedAt, r.IdledAt = to, now, &now
- r.StatusReason = fmt.Sprintf("%s after %d s idle; the next exec, file, port or keepalive call wakes it", action, rec.IdleTimeoutSeconds)
+ MarkPaused(r, now, releases)
+ r.StatusReason = reason()
return nil
})
if err != nil {
return
}
metrics.Reconciled.WithLabelValues("idle_"+action, rec.Pool).Inc()
+ if out.SlotReleased {
+ s.SlotFreed(ctx)
+ }
s.Audit.Record(ctx, api.AuditEvent{Actor: "scheduler", OrgID: out.OrgID, ProjectID: out.ProjectID, Action: "sandbox.idled", Resource: out.ID, Details: map[string]string{"pool": out.Pool, "action": action}})
}
@@ -615,15 +692,27 @@ func (s *Scheduler) Reconcile(ctx context.Context) {
func (s *Scheduler) reconcilePool(ctx context.Context, pool string, a adapter.ExecutionAdapter, instances []adapter.Instance) {
byRef := map[string]adapter.Instance{}
bySandbox := map[string]adapter.Instance{}
+ // Only instances that carry a sandbox ID are Differently's to manage. One
+ // without it was not created here (or the backend ignored the ownership
+ // filter and returned its whole account): it is never adopted or deleted.
+ owned := make([]adapter.Instance, 0, len(instances))
for _, in := range instances {
byRef[in.Ref] = in
+ if in.SandboxID == "" {
+ continue
+ }
bySandbox[in.SandboxID] = in
+ owned = append(owned, in)
+ }
+ if n := len(instances) - len(owned); n > 0 {
+ metrics.Reconciled.WithLabelValues("unowned_skipped", pool).Add(float64(n))
+ s.Log.Warn("reconcile left alone backend instances that carry no sandbox ID", "pool", pool, "count", n)
}
// Orphans: backend instances whose sandbox is gone, terminal or elsewhere.
// "Elsewhere" means placed on another pool with a different instance: two
// pools can front the same backend (one OpenSandbox server registered
// twice), so each sees the other's live instances and must leave them be.
- for _, in := range instances {
+ for _, in := range owned {
rec, err := s.Store.GetSandbox(ctx, in.SandboxID)
elsewhere := rec.Pool != pool && in.Ref != rec.BackendRef && rec.Status != api.StatusQueued && rec.Status != api.StatusProvisioning
orphan := api.IsCode(err, api.CodeNotFound) || (err == nil && (rec.Status.Terminal() || elsewhere))
@@ -637,7 +726,7 @@ func (s *Scheduler) reconcilePool(ctx context.Context, pool string, a adapter.Ex
metrics.Reconciled.WithLabelValues("orphan_deleted", pool).Inc()
s.Audit.Record(ctx, api.AuditEvent{Actor: "reconciler", OrgID: rec.OrgID, ProjectID: rec.ProjectID, Action: "reconcile.orphan_deleted", Resource: in.SandboxID, Details: map[string]string{"pool": pool, "ref": in.Ref}})
}
- recs, err := s.Store.ListSandboxes(ctx, store.SandboxFilter{Pool: pool, Statuses: []api.SandboxStatus{api.StatusProvisioning, api.StatusRunning, api.StatusPaused, api.StatusStopped}})
+ recs, err := s.Store.ListSandboxes(ctx, store.SandboxFilter{Pool: pool, Statuses: []api.SandboxStatus{api.StatusProvisioning, api.StatusRunning, api.StatusPausing, api.StatusPaused, api.StatusStopped}})
if err != nil {
return
}
@@ -651,9 +740,15 @@ func (s *Scheduler) reconcilePool(ctx context.Context, pool string, a adapter.Ex
}
stale := rec.ProvisionStartedAt == nil || now.Sub(*rec.ProvisionStartedAt) > timeout
if in, ok := bySandbox[rec.ID]; ok && stale {
- // Created before a crash: adopt it.
+ // Created before a crash: adopt it, unless it was deleted
+ // since the list was read (the terminator then needs the ref).
+ adopted := false
_, _ = s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
- r.BackendRef, r.Status, r.RunningSince, r.UpdatedAt = in.Ref, api.StatusRunning, &now, now
+ r.BackendRef = in.Ref
+ if adopted = r.Status == api.StatusProvisioning; !adopted {
+ return nil
+ }
+ r.Status, r.RunningSince, r.UpdatedAt = api.StatusRunning, &now, now
if r.StartedAt == nil {
r.StartedAt = &now
}
@@ -663,7 +758,9 @@ func (s *Scheduler) reconcilePool(ctx context.Context, pool string, a adapter.Ex
}
return nil
})
- metrics.Reconciled.WithLabelValues("adopted", pool).Inc()
+ if adopted {
+ metrics.Reconciled.WithLabelValues("adopted", pool).Inc()
+ }
} else if stale {
s.release(rec.ID)
s.retry(ctx, rec, adapter.Unavailable(errString("provisioning interrupted")))
@@ -692,7 +789,14 @@ func (s *Scheduler) reconcilePool(ctx context.Context, pool string, a adapter.Ex
// it idled (so the next call wakes it), and a backend wake of an idled
// sandbox (traffic through the backend's gateway) marks it running again.
func (s *Scheduler) reconcileState(ctx context.Context, pool string, rec store.SandboxRecord, in adapter.Instance) {
- if (rec.Status != api.StatusRunning && rec.Status != api.StatusPaused) || s.isInflight(rec.ID) || s.recentlyChanged(rec) {
+ if s.isInflight(rec.ID) || s.recentlyChanged(rec) {
+ return
+ }
+ if rec.Status == api.StatusPausing {
+ s.reconcilePausing(ctx, pool, rec, in)
+ return
+ }
+ if rec.Status != api.StatusRunning && rec.Status != api.StatusPaused {
return
}
switch in.State {
@@ -714,18 +818,22 @@ func (s *Scheduler) reconcileState(ctx context.Context, pool string, rec store.S
if rec.Status != api.StatusRunning || rec.IdleTimeoutSeconds <= 0 || !s.idlesNatively(pool) {
return
}
+ releases := s.ReleasesSlot(ctx, pool)
out, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
if r.Status != api.StatusRunning {
return nil
}
now := s.Now()
- stopClock(r, now)
r.Status, r.UpdatedAt, r.IdledAt = api.StatusPaused, now, &now
+ MarkPaused(r, now, releases)
r.StatusReason = fmt.Sprintf("paused by the backend after %d s idle; the next exec, file, port or keepalive call wakes it", rec.IdleTimeoutSeconds)
return nil
})
if err == nil && out.Status == api.StatusPaused {
metrics.Reconciled.WithLabelValues("idle_pause", pool).Inc()
+ if out.SlotReleased {
+ s.SlotFreed(ctx)
+ }
s.Audit.Record(ctx, api.AuditEvent{Actor: "reconciler", OrgID: rec.OrgID, ProjectID: rec.ProjectID, Action: "sandbox.idled", Resource: rec.ID, Details: map[string]string{"pool": pool, "action": "pause", "by": "backend"}})
}
case adapter.StateRunning:
@@ -736,9 +844,11 @@ func (s *Scheduler) reconcileState(ctx context.Context, pool string, rec store.S
if r.Status != api.StatusPaused || r.IdledAt == nil {
return nil
}
- now := s.Now()
- r.Status, r.UpdatedAt, r.IdledAt, r.StatusReason = api.StatusRunning, now, nil, ""
- r.RunningSince, r.LastActivityAt = &now, &now
+ // A wake through the backend's own gateway takes a slot back
+ // without asking; a full pool shows it as overcommitted until
+ // preemption or a pause frees one.
+ MarkRunning(r, s.Now())
+ r.StatusReason = ""
return nil
})
if err == nil && out.Status == api.StatusRunning {
@@ -751,6 +861,35 @@ func (s *Scheduler) reconcileState(ctx context.Context, pool string, rec store.S
}
}
+// reconcilePausing settles a pause whose waiter is gone: the replica that
+// asked for it stopped while the backend went on. A backend that finished
+// makes the sandbox paused. One still running is left until no waiter can be
+// left, since a retried pause reads as running between its attempts.
+func (s *Scheduler) reconcilePausing(ctx context.Context, pool string, rec store.SandboxRecord, in adapter.Instance) {
+ switch {
+ case in.State == adapter.StatePaused:
+ releases := s.ReleasesSlot(ctx, pool)
+ out, err := s.Store.UpdateSandbox(ctx, rec.ID, func(r *store.SandboxRecord) error {
+ if r.Status != api.StatusPausing {
+ return nil
+ }
+ now := s.Now()
+ r.Status, r.UpdatedAt = api.StatusPaused, now
+ MarkPaused(r, now, releases)
+ return nil
+ })
+ if err == nil && out.Status == api.StatusPaused {
+ metrics.Reconciled.WithLabelValues("pause_finished", pool).Inc()
+ if out.SlotReleased {
+ s.SlotFreed(ctx)
+ }
+ }
+ case in.State == adapter.StateRunning && s.Now().Sub(rec.UpdatedAt) > LifecycleTimeout:
+ s.abortPause(ctx, rec.ID)
+ metrics.Reconciled.WithLabelValues("pause_abandoned", pool).Inc()
+ }
+}
+
// recentlyChanged reports whether a lifecycle operation (stop, start, pause,
// resume) may still be in progress on another replica. The reconciler leaves
// such sandboxes alone instead of racing the operation.
diff --git a/internal/server/admin.go b/internal/server/admin.go
index 0a8aedd..804d7ba 100644
--- a/internal/server/admin.go
+++ b/internal/server/admin.go
@@ -260,6 +260,9 @@ func (s *Server) updatePool(w http.ResponseWriter, r *http.Request, p auth.Princ
MaxConcurrency *int `json:"maxConcurrency,omitempty"`
CostModel *api.CostModel `json:"costModel,omitempty"`
Region *string `json:"region,omitempty"`
+ // Preemption replaces the pool's preemption policy ({"mode":"off"}
+ // turns it off).
+ Preemption *api.Preemption `json:"preemption,omitempty"`
// CredentialRef moves an API-registered pool to another credential,
// for example from env:NAME to a stored secret:name.
CredentialRef *string `json:"credentialRef,omitempty"`
@@ -296,6 +299,12 @@ func (s *Server) updatePool(w http.ResponseWriter, r *http.Request, p auth.Princ
if req.Region != nil {
pool.Region = *req.Region
}
+ if req.Preemption != nil {
+ if err := config.ValidatePreemption(req.Preemption); err != nil {
+ return api.Errorf(api.CodeInvalidRequest, "%v", err)
+ }
+ pool.Preemption = req.Preemption
+ }
if err := s.Store.UpsertPool(r.Context(), pool); err != nil {
return err
}
diff --git a/internal/server/audit_order_test.go b/internal/server/audit_order_test.go
new file mode 100644
index 0000000..de25a7f
--- /dev/null
+++ b/internal/server/audit_order_test.go
@@ -0,0 +1,42 @@
+package server_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// The audit views ask for the newest events. Without order=desc a page holds
+// the oldest ones, so a busy installation never showed recent activity.
+func TestAuditEventsNewestFirst(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ for i := 0; i < 4; i++ {
+ h.must(201, "POST", "/v1/admin/organizations", operatorToken, map[string]string{"name": fmt.Sprintf("org-%d", i)}, nil)
+ }
+ list := func(query string) api.List[api.AuditEvent] {
+ t.Helper()
+ var out api.List[api.AuditEvent]
+ h.must(200, "GET", "/v1/audit-events?"+query, operatorToken, nil, &out)
+ return out
+ }
+ all := list("limit=1000")
+ total := int64(len(all.Items))
+ if total < 4 || all.Items[0].Seq != 1 {
+ t.Fatalf("default order should start at the oldest event: %d events, first seq %d", total, all.Items[0].Seq)
+ }
+ newest := list("order=desc&limit=2")
+ if len(newest.Items) != 2 || newest.Items[0].Seq != total || newest.Items[1].Seq != total-1 {
+ t.Fatalf("order=desc should return the two newest events (%d, %d): %+v", total, total-1, newest.Items)
+ }
+ if newest.NextCursor != fmt.Sprint(total-1) {
+ t.Fatalf("nextCursor %q, want the last sequence %d", newest.NextCursor, total-1)
+ }
+ older := list("order=desc&limit=2&beforeSeq=" + newest.NextCursor)
+ if len(older.Items) != 2 || older.Items[0].Seq != total-2 {
+ t.Fatalf("the next page should continue at %d: %+v", total-2, older.Items)
+ }
+ if code := h.call("GET", "/v1/audit-events?order=sideways", operatorToken, nil, nil); code != 400 {
+ t.Fatalf("an unknown order answered %d, want 400", code)
+ }
+}
diff --git a/internal/server/experimental_pool_test.go b/internal/server/experimental_pool_test.go
new file mode 100644
index 0000000..5a4036a
--- /dev/null
+++ b/internal/server/experimental_pool_test.go
@@ -0,0 +1,32 @@
+//go:build !experimental_adapters
+
+package server_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/different-ai/differently/internal/config"
+ "github.com/different-ai/differently/internal/wiring"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// A server built without the experimental adapters refuses a pool of one of
+// those kinds through the API, and says which build tag adds it.
+func TestDefaultBuildRefusesExperimentalPoolThroughAPI(t *testing.T) {
+ wiring.Register()
+ h := newHarness(t, api.PlacementPolicy{})
+ for _, kind := range config.ExperimentalAdapters {
+ var raw string
+ pool := map[string]any{"name": "p-" + kind, "adapter": kind, "trust": "external", "config": map[string]string{"apiURL": "http://backend.invalid"}, "credentials": map[string]string{"env": "PROVIDER_KEY"}}
+ if got := h.call("POST", "/v1/admin/pools", operatorToken, pool, &raw); got != 400 {
+ t.Fatalf("%s: status %d, want 400: %s", kind, got, raw)
+ }
+ if !strings.Contains(raw, api.CodeInvalidRequest) || !strings.Contains(raw, "-tags experimental_adapters") {
+ t.Errorf("%s: the refusal does not name the build tag: %s", kind, raw)
+ }
+ if got := h.call("GET", "/v1/admin/pools/p-"+kind, operatorToken, nil, &raw); got != 404 {
+ t.Errorf("%s: the refused pool was stored (status %d): %s", kind, got, raw)
+ }
+ }
+}
diff --git a/internal/server/pausing_test.go b/internal/server/pausing_test.go
new file mode 100644
index 0000000..9372585
--- /dev/null
+++ b/internal/server/pausing_test.go
@@ -0,0 +1,353 @@
+package server_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/different-ai/differently/internal/adapters/fake"
+ "github.com/different-ai/differently/internal/store"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// holdingPool is a one-slot checkpointing pool whose pauses take until the
+// returned function runs, like a Fast Sandbox checkpoint, with one sandbox
+// running on it.
+func holdingPool(t *testing.T, h *harness, pre *api.Preemption, cancels bool) (token string, pin *api.Placement, sb api.Sandbox, a *fake.Adapter, finish func()) {
+ t.Helper()
+ token, pin, a = checkpointBackend(t, h, pre)
+ hold := make(chan struct{})
+ a.HoldPauses, a.PauseCancels = hold, cancels
+ sb = h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ var once bool
+ finish = func() {
+ if !once {
+ once = true
+ close(hold)
+ }
+ }
+ t.Cleanup(finish)
+ return token, pin, sb, a, finish
+}
+
+// pause asks for a pause and returns once the sandbox is pausing, with a
+// channel that carries the pause request's HTTP status.
+func (h *harness) pause(token, id string) <-chan int {
+ h.t.Helper()
+ done := make(chan int, 1)
+ go func() { done <- h.call("POST", "/v1/sandboxes/"+id+"/pause", token, nil, nil) }()
+ h.waitFor(token, id, api.StatusPausing)
+ return done
+}
+
+func (h *harness) waitFor(token, id string, want api.SandboxStatus) api.Sandbox {
+ h.t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ got := h.get(token, id)
+ if got.Status == want {
+ return got
+ }
+ if time.Now().After(deadline) {
+ h.t.Fatalf("sandbox stayed %s, want %s", got.Status, want)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+func (h *harness) wrote(token, id string) int {
+ return h.call("PUT", "/v1/sandboxes/"+id+"/files/content?path=/work", token, []byte("work"), nil)
+}
+
+func (h *harness) ran(token, id string) int {
+ return h.call("POST", "/v1/sandboxes/"+id+"/exec", token, api.ExecRequest{Command: "echo work"}, nil)
+}
+
+// While a checkpoint uploads the VM still answers, but whatever it does next
+// is gone when the checkpoint is restored. So nothing new may start on it, a
+// caller can see why, and it keeps its slot until the pause is done.
+func TestPausingSandboxRefusesWork(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin, sb, _, finish := holdingPool(t, h, nil, false)
+ pausing := h.pause(token, sb.ID)
+
+ if got := h.ran(token, sb.ID); got != 409 {
+ t.Fatalf("exec during a pause: got %d, want 409", got)
+ }
+ if got := h.wrote(token, sb.ID); got != 409 {
+ t.Fatalf("a file write during a pause: got %d, want 409", got)
+ }
+ if got := h.call("POST", "/v1/sandboxes/"+sb.ID+"/keepalive", token, nil, nil); got != 409 {
+ t.Fatalf("keepalive on a caller's pause: got %d, want 409", got)
+ }
+ var again api.Sandbox
+ h.must(202, "POST", "/v1/sandboxes/"+sb.ID+"/pause", token, nil, &again)
+ if again.Status != api.StatusPausing {
+ t.Fatalf("a repeated pause should report the one under way: %s", again.Status)
+ }
+ next := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ if got := h.get(token, next.ID); got.Status != api.StatusQueued {
+ t.Fatalf("a pausing sandbox still holds its slot: %s", got.Status)
+ }
+
+ finish()
+ if got := <-pausing; got != 200 {
+ t.Fatalf("pause answered %d", got)
+ }
+ got := h.get(token, sb.ID)
+ if got.Status != api.StatusPaused || !got.SlotReleased || got.PausedAt == nil {
+ t.Fatalf("the pause should finish as usual: %+v", got)
+ }
+ h.tick()
+ if got := h.get(token, next.ID); got.Status != api.StatusRunning {
+ t.Fatalf("the freed slot should go to the waiting create: %s", got.Status)
+ }
+}
+
+// Someone coming back during a pause wants the sandbox they left. It is still
+// up, so the resume calls the pause off instead of waiting minutes to restore
+// a copy of it, and the run clock carries on.
+func TestResumeCallsOffAPause(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, _, sb, _, _ := holdingPool(t, h, nil, true)
+ time.Sleep(20 * time.Millisecond)
+ timed := pauseCount(t, "ckpt")
+ pausing := h.pause(token, sb.ID)
+
+ var back api.Sandbox
+ h.must(200, "POST", "/v1/sandboxes/"+sb.ID+"/resume", token, nil, &back)
+ if back.Status != api.StatusRunning || back.PausedAt != nil || back.SlotReleased {
+ t.Fatalf("resume should call the pause off: %+v", back)
+ }
+ if rec, err := h.store.GetSandbox(context.Background(), sb.ID); err != nil || rec.RunSeconds == 0 {
+ t.Fatalf("the run clock should carry on across a called-off pause: %v %v", rec.RunSeconds, err)
+ }
+ if got := <-pausing; got != 409 {
+ t.Fatalf("the pause's caller should hear it was called off: %d", got)
+ }
+ if got := pauseCount(t, "ckpt"); got != timed {
+ t.Fatalf("a called-off pause should not count as a pause's length: %d before, %d after", timed, got)
+ }
+ if got := h.ran(token, sb.ID); got != 200 {
+ t.Fatalf("exec after a called-off pause: %d", got)
+ }
+}
+
+// Where the backend cannot call a pause off, a resume joins the queue and runs
+// once the pause has finished, without holding a request open meanwhile.
+func TestResumeWaitsForAPauseItCannotCallOff(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, _, sb, _, finish := holdingPool(t, h, nil, false)
+ pausing := h.pause(token, sb.ID)
+
+ var queued api.Sandbox
+ h.must(202, "POST", "/v1/sandboxes/"+sb.ID+"/resume", token, nil, &queued)
+ if queued.Status != api.StatusPausing || queued.ResumeRequestedAt == nil || !strings.Contains(queued.StatusReason, "pause finishes") {
+ t.Fatalf("resume should wait for the pause: %+v", queued)
+ }
+ finish()
+ <-pausing
+ h.tick()
+ if got := h.get(token, sb.ID); got.Status != api.StatusRunning || got.ResumeRequestedAt != nil {
+ t.Fatalf("the queue should resume it after the pause: %+v", got)
+ }
+}
+
+func TestFailedPauseGoesBackToRunning(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin, a := checkpointBackend(t, h, nil)
+ a.FailPauses = 1
+ sb := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ timed := pauseCount(t, "ckpt")
+ if got := h.call("POST", "/v1/sandboxes/"+sb.ID+"/pause", token, nil, nil); got < 500 {
+ t.Fatalf("a failed pause should answer an error: %d", got)
+ }
+ if got := pauseCount(t, "ckpt"); got != timed {
+ t.Fatalf("a failed pause should not count as a pause's length: %d before, %d after", timed, got)
+ }
+ if got := h.get(token, sb.ID); got.Status != api.StatusRunning {
+ t.Fatalf("a failed pause leaves the sandbox running: %s", got.Status)
+ }
+ if got := h.ran(token, sb.ID); got != 200 {
+ t.Fatalf("exec after a failed pause: %d", got)
+ }
+}
+
+// Deleting a sandbox mid-pause stops the wait for it.
+func TestDeleteDuringAPause(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, _, sb, _, _ := holdingPool(t, h, nil, false)
+ pausing := h.pause(token, sb.ID)
+ h.must(202, "DELETE", "/v1/sandboxes/"+sb.ID, token, nil, nil)
+ if got := <-pausing; got != 409 {
+ t.Fatalf("the pause's caller should hear the sandbox changed: %d", got)
+ }
+ h.tick()
+ if got := h.get(token, sb.ID); got.Status != api.StatusTerminated {
+ t.Fatalf("deleted: %s", got.Status)
+ }
+}
+
+// backdate makes a sandbox look unused for d, like a computer resumed that long
+// ago and left alone.
+func (h *harness) backdate(id string, d time.Duration) {
+ h.t.Helper()
+ at := time.Now().UTC().Add(-d)
+ if _, err := h.store.UpdateSandbox(context.Background(), id, func(r *store.SandboxRecord) error {
+ r.LastActivityAt = &at
+ return nil
+ }); err != nil {
+ h.t.Fatal(err)
+ }
+}
+
+// A sandbox in use is not idle, whether or not it has an idle timeout of its
+// own: pausing it to make room would lose the work under way.
+func TestPreemptionSparesASandboxInUse(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin := checkpointPool(t, h, &api.Preemption{Mode: api.PreemptIdle, IdleSeconds: 60})
+ busy := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ h.backdate(busy.ID, 2*time.Minute)
+ if got := h.ran(token, busy.ID); got != 200 {
+ t.Fatalf("exec: %d", got)
+ }
+ h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ if got := h.get(token, busy.ID); got.Status != api.StatusRunning {
+ t.Fatalf("a sandbox that just ran a command is not idle: %s", got.Status)
+ }
+ h.backdate(busy.ID, 2*time.Minute)
+ h.tick()
+ if got := h.get(token, busy.ID); got.Status != api.StatusPaused {
+ t.Fatalf("once idle it makes room: %s", got.Status)
+ }
+}
+
+// A command that outlasts the idle window is activity for as long as it runs.
+func TestRunningCommandCountsAsActivity(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ h.api.ActivityBeat = 20 * time.Millisecond
+ token, pin, a := checkpointBackend(t, h, &api.Preemption{Mode: api.PreemptIdle, IdleSeconds: 60})
+ busy := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ h.backdate(busy.ID, 2*time.Minute)
+ hold := make(chan struct{})
+ a.HoldExecs = hold
+ running := make(chan int, 1)
+ go func() { running <- h.ran(token, busy.ID) }()
+ defer func() {
+ close(hold)
+ if got := <-running; got != 200 {
+ t.Errorf("exec: %d", got)
+ }
+ }()
+ h.backdate(busy.ID, 2*time.Minute) // after the exec's own start counted
+ deadline := time.Now().Add(5 * time.Second)
+ for got := h.get(token, busy.ID); time.Since(*got.LastActivityAt) > time.Minute; got = h.get(token, busy.ID) {
+ if time.Now().After(deadline) {
+ t.Fatal("a running command should keep counting as activity")
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ if got := h.get(token, busy.ID); got.Status != api.StatusRunning {
+ t.Fatalf("a sandbox in the middle of a command is not idle: %s", got.Status)
+ }
+}
+
+// A sandbox being paused to make room keeps the idle contract: the next call
+// brings it back, and here that means it never stopped.
+func TestCallWakesASandboxBeingPreempted(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin, sb, _, _ := holdingPool(t, h, &api.Preemption{Mode: api.PreemptLRU, MinRunSeconds: 60}, true)
+ later := time.Now().UTC().Add(5 * time.Minute)
+ h.sched.Now = func() time.Time { return later }
+ h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ ctx := context.Background()
+ h.sched.Tick(ctx)
+ h.sched.Tick(ctx)
+ parked := h.waitFor(token, sb.ID, api.StatusPausing)
+ if parked.IdledAt == nil || !strings.Contains(parked.StatusReason, "make room") {
+ t.Fatalf("a preempted sandbox should say why: %+v", parked)
+ }
+ if got := h.ran(token, sb.ID); got != 200 {
+ t.Fatalf("a call should wake a sandbox being preempted: %d", got)
+ }
+ if got := h.get(token, sb.ID); got.Status != api.StatusRunning || got.IdledAt != nil {
+ t.Fatalf("called off: %+v", got)
+ }
+}
+
+func TestIdleSweepShowsItsPause(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ _, _, token := h.tenant("a", api.Quota{})
+ hold := make(chan struct{})
+ h.fc.HoldPauses = hold
+ sb := h.create(token, api.CreateSandboxRequest{IdleTimeoutSeconds: 60})
+ h.tick()
+ h.sched.Now = func() time.Time { return time.Now().UTC().Add(2 * time.Minute) }
+ swept := make(chan struct{})
+ go func() {
+ defer close(swept)
+ h.sched.SweepIdle(context.Background())
+ }()
+ if got := h.waitFor(token, sb.ID, api.StatusPausing); got.IdledAt == nil {
+ t.Fatalf("an idle pause should be marked idled from the start: %+v", got)
+ }
+ close(hold)
+ <-swept
+ if got := h.get(token, sb.ID); got.Status != api.StatusPaused || got.IdledAt == nil {
+ t.Fatalf("idle pause: %+v", got)
+ }
+}
+
+// The replica that asked for a pause can stop while the backend goes on: the
+// reconciler settles what it left behind.
+func TestReconcilerSettlesAPauseWithNoWaiter(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin, a := checkpointBackend(t, h, nil)
+ ctx := context.Background()
+ orphan := func() api.Sandbox {
+ sb := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ if _, err := h.store.UpdateSandbox(ctx, sb.ID, func(r *store.SandboxRecord) error {
+ r.Status = api.StatusPausing
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return sb
+ }
+
+ finished := orphan()
+ if err := a.Pause(ctx, a.RefOf(finished.ID)); err != nil {
+ t.Fatal(err)
+ }
+ h.sched.Reconcile(ctx)
+ if got := h.get(token, finished.ID); got.Status != api.StatusPaused || !got.SlotReleased {
+ t.Fatalf("a finished pause should be followed: %+v", got)
+ }
+ if _, err := h.store.UpdateSandbox(ctx, finished.ID, func(r *store.SandboxRecord) error {
+ r.Status = api.StatusTerminated
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ stuck := orphan()
+ h.sched.Reconcile(ctx)
+ if got := h.get(token, stuck.ID); got.Status != api.StatusPausing {
+ t.Fatalf("a retrying pause reads as running between attempts: %s", got.Status)
+ }
+ h.sched.Now = func() time.Time { return time.Now().UTC().Add(time.Hour) }
+ h.sched.Reconcile(ctx)
+ if got := h.get(token, stuck.ID); got.Status != api.StatusRunning {
+ t.Fatalf("a pause nobody is waiting for that never happened: %s", got.Status)
+ }
+}
diff --git a/internal/server/placement_fail_closed_test.go b/internal/server/placement_fail_closed_test.go
new file mode 100644
index 0000000..01dc502
--- /dev/null
+++ b/internal/server/placement_fail_closed_test.go
@@ -0,0 +1,64 @@
+package server_test
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+
+ "github.com/different-ai/differently/internal/store"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// flakyPolicies is a store whose policy reads fail while failing is set, as a
+// database blip would make them.
+type flakyPolicies struct {
+ store.Store
+ failing atomic.Bool
+}
+
+func (f *flakyPolicies) GetPolicy(ctx context.Context, scope string) (api.PlacementPolicy, bool, error) {
+ if f.failing.Load() && scope != "global" {
+ return api.PlacementPolicy{}, false, errors.New("connection reset")
+ }
+ return f.Store.GetPolicy(ctx, scope)
+}
+
+// A policy that cannot be read must never be treated as absent: with the
+// organization's "external: never" unreadable, queued work stays queued
+// instead of overflowing to the external pool the global policy allows.
+func TestPlacementFailsClosedWhenPolicyUnreadable(t *testing.T) {
+ var flaky *flakyPolicies
+ h := newHarnessWith(t, overflowPolicy, nil, func(st store.Store) store.Store {
+ flaky = &flakyPolicies{Store: st}
+ return flaky
+ })
+ orgID, _, token := h.tenant("acme", api.Quota{})
+ h.must(200, "PUT", "/v1/policies/org:"+orgID, operatorToken, api.PlacementPolicy{External: api.ExternalNever}, nil)
+ h.create(token, api.CreateSandboxRequest{Resources: &api.Resources{CPU: 4, MemoryMiB: 4096}}) // fills the private pool
+ h.tick()
+ h.sched.Pools.Refresh(context.Background())
+ waiting := h.create(token, api.CreateSandboxRequest{Resources: &api.Resources{CPU: 2, MemoryMiB: 2048}})
+
+ flaky.failing.Store(true)
+ h.tick()
+ if got := h.get(token, waiting.ID); got.Status != api.StatusQueued || got.Pool != "" {
+ t.Fatalf("sandbox was placed while its organization policy was unreadable: %+v", got)
+ }
+ if h.ext.Count() != 0 {
+ t.Fatalf("external pool received %d instances", h.ext.Count())
+ }
+ var env api.ErrorEnvelope
+ if code := h.call("POST", "/v1/sandboxes", token, api.CreateSandboxRequest{}, &env); code != 503 || env.Error == nil || env.Error.Code != api.CodeProviderUnavailable {
+ t.Fatalf("create with unreadable policy: status %d, body %+v", code, env.Error)
+ }
+ if code := h.call("GET", "/v1/capabilities", token, nil, nil); code != 503 {
+ t.Fatalf("capabilities with unreadable policy: status %d", code)
+ }
+
+ flaky.failing.Store(false)
+ h.tick()
+ if got := h.get(token, waiting.ID); got.Status != api.StatusQueued || got.Trust == api.TrustExternal {
+ t.Fatalf("sandbox left private pools after the policy was readable again: %+v", got)
+ }
+}
diff --git a/internal/server/sandboxes.go b/internal/server/sandboxes.go
index eae7dc5..e09f1f6 100644
--- a/internal/server/sandboxes.go
+++ b/internal/server/sandboxes.go
@@ -2,6 +2,7 @@ package server
import (
"context"
+ "errors"
"fmt"
"io"
"net/http"
@@ -114,7 +115,11 @@ func (s *Server) admit(w http.ResponseWriter, r *http.Request, p auth.Principal,
}
// Fail fast with a precise error when no pool could ever take this.
active, _ := s.Store.PoolActive(ctx)
- if res := policy.Decide(s.Scheduler.PlacementInput(ctx, rec, active)); res.Err != nil {
+ in, err := s.Scheduler.PlacementInput(ctx, rec, active)
+ if err != nil {
+ return s.placementUnavailable(err)
+ }
+ if res := policy.Decide(in); res.Err != nil {
return res.Err
}
out, existed, err := s.Store.AdmitSandbox(ctx, rec, store.Admission{OrgQuota: org.Quota, ProjectQuota: proj.Quota})
@@ -133,6 +138,14 @@ func (s *Server) admit(w http.ResponseWriter, r *http.Request, p auth.Principal,
return nil
}
+// placementUnavailable reports that placement could not be evaluated because
+// a policy or snapshot read failed. The cause is logged, not returned: it can
+// name internal hosts.
+func (s *Server) placementUnavailable(err error) error {
+ s.Log.Warn("placement input unavailable", "err", err)
+ return api.Errorf(api.CodeProviderUnavailable, "placement policy could not be read; nothing was changed, retry shortly")
+}
+
func (s *Server) buildSandbox(ctx context.Context, p auth.Principal, req api.CreateSandboxRequest) (store.SandboxRecord, error) {
org, proj, err := s.resolveProject(ctx, p, req.ProjectID)
if err != nil {
@@ -205,14 +218,26 @@ func (s *Server) buildSandbox(ctx context.Context, p auth.Principal, req api.Cre
return store.SandboxRecord{}, api.Errorf(api.CodeInvalidRequest, "placement.external must be never, overflow or allowed")
}
}
+ // A negative TTL would never expire and would slip under maxTTLSeconds.
ttl := req.TTLSeconds
- if ttl == 0 {
+ switch {
+ case ttl < 0:
+ return store.SandboxRecord{}, api.Errorf(api.CodeInvalidRequest, "ttlSeconds must be positive, or 0 for the operator default")
+ case ttl == 0:
ttl = s.Config.Defaults.TTLSeconds
}
idle, err := idleTimeout(req.IdleTimeoutSeconds, s.Config.Defaults.IdleTimeoutSeconds)
if err != nil {
return store.SandboxRecord{}, err
}
+ maxRun, err := lifecycleLimit("maxRunSeconds", req.MaxRunSeconds, s.Config.Defaults.MaxRunSeconds)
+ if err != nil {
+ return store.SandboxRecord{}, err
+ }
+ maxPaused, err := lifecycleLimit("maxPausedSeconds", req.MaxPausedSeconds, s.Config.Defaults.MaxPausedSeconds)
+ if err != nil {
+ return store.SandboxRecord{}, err
+ }
for k := range req.Labels {
if strings.HasPrefix(k, "differently.dev/") {
return store.SandboxRecord{}, api.Errorf(api.CodeInvalidRequest, "label prefix differently.dev/ is reserved")
@@ -225,6 +250,7 @@ func (s *Server) buildSandbox(ctx context.Context, p auth.Principal, req api.Cre
Resources: resources, Capabilities: caps, Classification: class, Placement: req.Placement, Labels: req.Labels,
Priority: req.Priority, TTLSeconds: ttl, CreatedBy: p.Actor(), CreatedAt: now, UpdatedAt: now, FromSnapshot: req.FromSnapshot,
IdleTimeoutSeconds: idle, LastActivityAt: &now,
+ MaxRunSeconds: maxRun, MaxPausedSeconds: maxPaused, Preemptible: req.Preemptible == nil || *req.Preemptible,
},
Env: req.Env, NextAttemptAt: now,
}
@@ -316,7 +342,28 @@ func (s *Server) updateSandbox(w http.ResponseWriter, r *http.Request, p auth.Pr
}
idle = &v
}
+ var maxRun, maxPaused *int
+ for _, f := range []struct {
+ name string
+ in *int
+ def int
+ out **int
+ }{{"maxRunSeconds", req.MaxRunSeconds, s.Config.Defaults.MaxRunSeconds, &maxRun}, {"maxPausedSeconds", req.MaxPausedSeconds, s.Config.Defaults.MaxPausedSeconds, &maxPaused}} {
+ if f.in == nil {
+ continue
+ }
+ v, err := lifecycleLimit(f.name, *f.in, f.def)
+ if err != nil {
+ return err
+ }
+ *f.out = &v
+ }
if req.TTLSeconds != nil {
+ // The new TTL counts from now, so 0 would expire the sandbox at once
+ // (delete it instead) and a negative one is already in the past.
+ if *req.TTLSeconds <= 0 {
+ return api.Errorf(api.CodeInvalidRequest, "ttlSeconds must be positive; delete the sandbox to end it now")
+ }
for _, q := range []api.Quota{org.Quota, proj.Quota} {
if q.MaxTTLSeconds > 0 && *req.TTLSeconds > q.MaxTTLSeconds {
return api.Errorf(api.CodeQuotaExceeded, "ttl exceeds limit %ds", q.MaxTTLSeconds).With("dimension", "ttlSeconds")
@@ -343,6 +390,15 @@ func (s *Server) updateSandbox(w http.ResponseWriter, r *http.Request, p auth.Pr
if idle != nil {
x.IdleTimeoutSeconds = *idle
}
+ if maxRun != nil {
+ x.MaxRunSeconds = *maxRun
+ }
+ if maxPaused != nil {
+ x.MaxPausedSeconds = *maxPaused
+ }
+ if req.Preemptible != nil {
+ x.Preemptible = *req.Preemptible
+ }
if req.TTLSeconds != nil {
x.TTLSeconds = *req.TTLSeconds
if x.StartedAt != nil {
@@ -462,12 +518,35 @@ func (s *Server) explainPlacement(w http.ResponseWriter, r *http.Request, p auth
out := map[string]any{"sandboxId": rec.ID, "status": rec.Status, "pool": rec.Pool, "trust": rec.Trust, "decision": rec.Decision}
if rec.Status == api.StatusQueued {
active, _ := s.Store.PoolActive(r.Context())
- out["current"] = policy.Decide(s.Scheduler.PlacementInput(r.Context(), rec, active)).Decision
+ in, err := s.Scheduler.PlacementInput(r.Context(), rec, active)
+ if err != nil {
+ return s.placementUnavailable(err)
+ }
+ out["current"] = policy.Decide(in).Decision
}
writeJSON(w, 200, out)
return nil
}
+// awaitResume waits up to wakeWait for a queued resume to finish.
+func (s *Server) awaitResume(ctx context.Context, sid string) (store.SandboxRecord, error) {
+ deadline := time.Now().Add(wakeWait)
+ for {
+ rec, err := s.Store.GetSandbox(ctx, sid)
+ if err != nil || rec.Status == api.StatusRunning {
+ return rec, err
+ }
+ if time.Now().After(deadline) || rec.Status != api.StatusPaused && rec.Status != api.StatusPausing {
+ return rec, api.Errorf(scheduler.CodeInsufficientCapacity, "pool %s is full; the sandbox is queued to resume when a slot frees", rec.Pool).With("pool", rec.Pool)
+ }
+ select {
+ case <-ctx.Done():
+ return rec, ctx.Err()
+ case <-time.After(500 * time.Millisecond):
+ }
+ }
+}
+
// running returns the sandbox's adapter after checking it is running.
func (s *Server) running(r *http.Request, p auth.Principal, scope string) (store.SandboxRecord, adapter.ExecutionAdapter, error) {
if err := p.Require(scope); err != nil {
@@ -477,14 +556,18 @@ func (s *Server) running(r *http.Request, p auth.Principal, scope string) (store
if err != nil {
return rec, nil, err
}
- if rec.IdledAt != nil && (rec.Status == api.StatusStopped || rec.Status == api.StatusPaused) {
+ if rec.IdledAt != nil && (rec.Status == api.StatusStopped || rec.Status == api.StatusPaused || rec.Status == api.StatusPausing) {
// Wake on access: the sandbox was stopped or paused for being idle,
// not by a caller, so the next call brings it back.
- action := "start"
- if rec.Status == api.StatusPaused {
- action = "resume"
+ action := "resume"
+ if rec.Status == api.StatusStopped {
+ action = "start"
}
woken, err := s.transition(r.Context(), rec, action)
+ if errors.Is(err, errResumeQueued) {
+ // Wait for the queue to bring it back, briefly.
+ woken, err = s.awaitResume(r.Context(), rec.ID)
+ }
if err != nil && !api.IsCode(err, api.CodeConflict) {
return rec, nil, err
}
@@ -503,11 +586,12 @@ func (s *Server) running(r *http.Request, p auth.Principal, scope string) (store
return rec, a, err
}
-// touch records activity for idle auto-stop, at most once per few seconds
-// per sandbox so busy sandboxes do not write on every call.
+// touch records activity for idle auto-stop and idle preemption, at most once
+// per few seconds per sandbox so busy sandboxes do not write on every call.
func (s *Server) touch(ctx context.Context, rec store.SandboxRecord) {
now := s.now()
- if rec.IdleTimeoutSeconds <= 0 || rec.LastActivityAt != nil && now.Sub(*rec.LastActivityAt) < 5*time.Second {
+ watched := rec.IdleTimeoutSeconds > 0 || rec.Preemptible
+ if !watched || rec.LastActivityAt != nil && now.Sub(*rec.LastActivityAt) < 5*time.Second {
return
}
_, _ = s.Store.UpdateSandbox(ctx, rec.ID, func(x *store.SandboxRecord) error {
@@ -516,6 +600,29 @@ func (s *Server) touch(ctx context.Context, rec store.SandboxRecord) {
})
}
+// activeWhile counts a sandbox as active until the returned function runs, so
+// a long command does not look idle and get paused halfway through.
+func (s *Server) activeWhile(ctx context.Context, rec store.SandboxRecord) func() {
+ ctx = context.WithoutCancel(ctx)
+ done := make(chan struct{})
+ go func() {
+ t := time.NewTicker(s.ActivityBeat)
+ defer t.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-t.C:
+ s.touch(ctx, rec)
+ }
+ }
+ }()
+ return func() {
+ close(done)
+ s.touch(ctx, rec)
+ }
+}
+
// keepalive counts as activity, and wakes an idled sandbox. Clients that use
// a sandbox in ways the control plane does not see (a desktop viewer on a
// port URL) call it to keep the sandbox from being stopped.
@@ -546,6 +653,21 @@ func idleTimeout(requested, def int) (int, error) {
return requested, nil
}
+// lifecycleLimit resolves maxRunSeconds or maxPausedSeconds like the idle
+// timeout: 0 takes the default, -1 turns it off, and anything set is at least
+// 60.
+func lifecycleLimit(name string, requested, def int) (int, error) {
+ switch {
+ case requested == 0:
+ return def, nil
+ case requested == -1:
+ return 0, nil
+ case requested < 60:
+ return 0, api.Errorf(api.CodeInvalidRequest, "%s must be at least 60, or -1 to turn it off", name)
+ }
+ return requested, nil
+}
+
func unsupported(rec store.SandboxRecord, op string) error {
return api.Errorf(api.CodeUnsupportedOperation, "the %s backend of pool %s does not support %s", rec.Adapter, rec.Pool, op).
With("operation", op).With("pool", rec.Pool).With("adapter", rec.Adapter)
@@ -556,7 +678,7 @@ func (s *Server) exec(w http.ResponseWriter, r *http.Request, p auth.Principal)
if err != nil {
return err
}
- defer s.touch(context.WithoutCancel(r.Context()), rec) // a long command was activity throughout
+ defer s.activeWhile(r.Context(), rec)()
var req api.ExecRequest
if err := decode(r, &req); err != nil {
return err
@@ -699,8 +821,13 @@ func (s *Server) writeFile(w http.ResponseWriter, r *http.Request, p auth.Princi
return nil
}
-// transitionTimeout bounds one stop, start, pause or resume on a backend.
-const transitionTimeout = 15 * time.Minute
+// errResumeQueued means a resume found its pool full and now waits in the
+// pool's resume queue.
+var errResumeQueued = errors.New("resume queued: the pool is full")
+
+// wakeWait is how long a call that wakes an idled sandbox waits for a slot
+// before answering insufficient_capacity (the resume stays queued).
+const wakeWait = 60 * time.Second
// lifecycle handles stop/start/pause/resume.
var lifecycleSteps = map[string]struct {
@@ -726,10 +853,25 @@ func (s *Server) lifecycle(action string) handlerFunc {
writeJSON(w, 200, rec.Sandbox) // idempotent
return nil
}
+ if action == "pause" && rec.Status == api.StatusPausing {
+ // Already under way: GET /v1/sandboxes/{id}/wait?status=paused.
+ writeJSON(w, http.StatusAccepted, rec.Sandbox)
+ return nil
+ }
out, err := s.transition(r.Context(), rec, action)
+ if errors.Is(err, errResumeQueued) {
+ // The pool is full: the resume waits in its queue and runs as soon
+ // as a slot frees (GET /v1/sandboxes/{id}/wait?status=running).
+ s.audit(r, p, "sandbox.resume_queued", out.ID, out.OrgID, out.ProjectID, map[string]string{"pool": out.Pool})
+ writeJSON(w, http.StatusAccepted, out.Sandbox)
+ return nil
+ }
if err != nil {
return err
}
+ if out.SlotReleased {
+ s.Scheduler.SlotFreed(r.Context())
+ }
s.audit(r, p, "sandbox."+action, out.ID, out.OrgID, out.ProjectID, nil)
writeJSON(w, 200, out.Sandbox)
return nil
@@ -739,14 +881,20 @@ func (s *Server) lifecycle(action string) handlerFunc {
// transition stops, starts, pauses or resumes a sandbox on its backend and
// records the new status. A caller's stop or pause clears any idle marker,
// so the sandbox is not woken by the next call; a start or resume counts as
-// activity.
+// activity. A resume during a pause calls the pause off where the backend can
+// (adapter.PauseCanceller), and otherwise joins the resume queue, which runs
+// it when the pause finishes.
func (s *Server) transition(ctx context.Context, rec store.SandboxRecord, action string) (store.SandboxRecord, error) {
// The change outlives the request: a Fast Sandbox pause writes a full
// checkpoint and can take minutes, and a client that stops waiting must
// not leave the backend paused while the record still says running.
- ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), transitionTimeout)
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), scheduler.LifecycleTimeout)
defer cancel()
st := lifecycleSteps[action]
+ callingOff := action == "resume" && rec.Status == api.StatusPausing
+ if callingOff {
+ st.from = api.StatusPausing
+ }
if rec.Status != st.from {
return rec, api.Errorf(api.CodeInvalidState, "cannot %s a %s sandbox", action, rec.Status).With("status", string(rec.Status))
}
@@ -757,6 +905,28 @@ func (s *Server) transition(ctx context.Context, rec store.SandboxRecord, action
if !s.Pools.Supports(ctx, rec.Pool, st.feature) {
return rec, unsupported(rec, st.feature)
}
+ if c, ok := a.(adapter.PauseCanceller); callingOff && (!ok || !c.CancelsPause()) {
+ queued, err := s.Scheduler.QueueResume(ctx, rec.ID)
+ if err != nil {
+ return rec, err
+ }
+ return queued, errResumeQueued
+ }
+ if action == "resume" && rec.SlotReleased {
+ // The pause gave the slot back: take one again, or wait for one.
+ reclaimed, err := s.Scheduler.ReclaimSlot(ctx, rec)
+ if api.IsCode(err, scheduler.CodeInsufficientCapacity) {
+ queued, qerr := s.Scheduler.QueueResume(ctx, rec.ID)
+ if qerr != nil {
+ return rec, qerr
+ }
+ return queued, errResumeQueued
+ }
+ if err != nil {
+ return rec, err
+ }
+ rec = reclaimed
+ }
if action == "start" {
// Starting re-reserves CPU and memory: check quota atomically
// first, and release the reservation if the backend fails.
@@ -786,7 +956,8 @@ func (s *Server) transition(ctx context.Context, rec store.SandboxRecord, action
return rec, unsupported(rec, st.feature)
}
if action == "pause" {
- err = pz.Pause(ctx, rec.BackendRef)
+ err = s.Scheduler.Pause(ctx, pz, rec, nil)
+ st.from = api.StatusPausing
} else {
err = pz.Resume(ctx, rec.BackendRef)
}
@@ -800,20 +971,31 @@ func (s *Server) transition(ctx context.Context, rec store.SandboxRecord, action
return nil
})
}
+ if action == "resume" && s.Scheduler.ReleasesSlot(ctx, rec.Pool) {
+ // Still paused: give back the slot this resume took.
+ _, _ = s.Store.UpdateSandbox(ctx, rec.ID, func(x *store.SandboxRecord) error {
+ if x.Status == api.StatusPaused {
+ x.SlotReleased = true
+ }
+ return nil
+ })
+ }
return rec, err
}
+ releases := st.to == api.StatusPaused && s.Scheduler.ReleasesSlot(ctx, rec.Pool)
return s.Store.UpdateSandbox(ctx, rec.ID, func(x *store.SandboxRecord) error {
- if x.Status == st.to && st.to == api.StatusStopped {
- return nil // the reconciler saw the backend stop first
+ if x.Status == st.to && st.to != api.StatusRunning {
+ return nil // the reconciler saw the backend stop or pause first
}
- if x.Status != st.from {
+ if x.Status != st.from && !(callingOff && x.Status == api.StatusPaused) {
return api.Errorf(api.CodeConflict, "sandbox changed to %s concurrently", x.Status)
}
now := s.now()
if st.to == api.StatusRunning {
- x.RunningSince, x.LastActivityAt = &now, &now
+ scheduler.MarkRunning(x, now)
} else {
- scheduler.StopClock(x, now)
+ x.Status = st.to
+ scheduler.MarkPaused(x, now, releases)
}
x.Status, x.UpdatedAt, x.IdledAt, x.StatusReason = st.to, now, nil, ""
return nil
diff --git a/internal/server/scheduler_races_test.go b/internal/server/scheduler_races_test.go
new file mode 100644
index 0000000..4fade42
--- /dev/null
+++ b/internal/server/scheduler_races_test.go
@@ -0,0 +1,99 @@
+package server_test
+
+import (
+ "context"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/different-ai/differently/internal/store"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// racingStore runs afterList right after a sandbox list is read, where a
+// request on another replica could change a sandbox the scheduler is about to
+// write.
+type racingStore struct {
+ store.Store
+ afterList func(store.SandboxFilter)
+}
+
+func (r *racingStore) ListSandboxes(ctx context.Context, f store.SandboxFilter) ([]store.SandboxRecord, error) {
+ out, err := r.Store.ListSandboxes(ctx, f)
+ if r.afterList != nil {
+ r.afterList(f)
+ }
+ return out, err
+}
+
+// The scheduler decides on a list it read a moment ago. A TTL extension or a
+// delete that lands in between must win over the stale decision.
+func TestSchedulerDoesNotOverwriteConcurrentChanges(t *testing.T) {
+ var racing *racingStore
+ h := newHarnessWith(t, api.PlacementPolicy{}, nil, func(st store.Store) store.Store {
+ racing = &racingStore{Store: st}
+ return racing
+ })
+ ctx := context.Background()
+ _, _, token := h.tenant("acme", api.Quota{})
+ // once runs change the first time the scheduler reads the list matching when.
+ once := func(when func(store.SandboxFilter) bool, change func(*store.SandboxRecord), id string) {
+ racing.afterList = func(f store.SandboxFilter) {
+ if when(f) {
+ racing.afterList = nil
+ _, _ = h.store.UpdateSandbox(ctx, id, func(r *store.SandboxRecord) error { change(r); return nil })
+ }
+ }
+ }
+ expiryList := func(f store.SandboxFilter) bool {
+ return f.Pool == "" && slices.Contains(f.Statuses, api.StatusStopped)
+ }
+ // The sandboxes run on "fc"; the change must land after that pool's
+ // records were read, not while another pool is reconciled first.
+ reconcileList := func(f store.SandboxFilter) bool { return f.Pool == "fc" }
+ deleted := func(r *store.SandboxRecord) { r.Status, r.StatusReason = api.StatusTerminating, "deleted" }
+
+ // 1. Expired when listed, extended before the write: it keeps running.
+ extended := h.create(token, api.CreateSandboxRequest{})
+ h.tick()
+ past, later := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
+ _, _ = h.store.UpdateSandbox(ctx, extended.ID, func(r *store.SandboxRecord) error { r.ExpiresAt = &past; return nil })
+ once(expiryList, func(r *store.SandboxRecord) { r.ExpiresAt = &later }, extended.ID)
+ h.tick()
+ if got := h.get(token, extended.ID); got.Status != api.StatusRunning {
+ t.Fatalf("a sandbox whose TTL was extended was expired anyway: %s (%s)", got.Status, got.StatusReason)
+ }
+
+ // 2. Its instance vanished, and it was deleted before the reconciler
+ // wrote "failed": the delete stands.
+ lost := h.create(token, api.CreateSandboxRequest{})
+ h.tick()
+ rec, _ := h.store.GetSandbox(ctx, lost.ID)
+ h.fc.Forget(rec.BackendRef)
+ once(reconcileList, deleted, lost.ID)
+ h.sched.Reconcile(ctx)
+ if got := h.get(token, lost.ID); got.Status != api.StatusTerminating {
+ t.Fatalf("a deleted sandbox was overwritten as %s (%s)", got.Status, got.StatusReason)
+ }
+
+ // 3. Provisioning was interrupted and the instance exists; it was deleted
+ // before the reconciler adopted it: it must not come back as running.
+ adopted := h.create(token, api.CreateSandboxRequest{})
+ h.tick()
+ longAgo := time.Now().Add(-time.Hour) // well past the provision timeout
+ _, _ = h.store.UpdateSandbox(ctx, adopted.ID, func(r *store.SandboxRecord) error {
+ r.Status, r.ProvisionStartedAt = api.StatusProvisioning, &longAgo
+ return nil
+ })
+ once(reconcileList, deleted, adopted.ID)
+ h.sched.Reconcile(ctx)
+ if got := h.get(token, adopted.ID); got.Status != api.StatusTerminating {
+ t.Fatalf("a deleted sandbox came back as %s", got.Status)
+ }
+ h.tick()
+ for _, id := range []string{lost.ID, adopted.ID} {
+ if got := h.get(token, id); got.Status != api.StatusTerminated {
+ t.Fatalf("deleted sandbox %s ended as %s", id, got.Status)
+ }
+ }
+}
diff --git a/internal/server/scheduler_stall_test.go b/internal/server/scheduler_stall_test.go
new file mode 100644
index 0000000..cc988a9
--- /dev/null
+++ b/internal/server/scheduler_stall_test.go
@@ -0,0 +1,56 @@
+package server_test
+
+import (
+ "context"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/different-ai/differently/internal/adapters/fake"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// stuckHealth is a pool whose backend stops answering: its health probe hangs
+// until the probe's own timeout.
+type stuckHealth struct {
+ *fake.Adapter
+ stuck atomic.Bool
+}
+
+func (a *stuckHealth) Health(ctx context.Context) error {
+ if a.stuck.Load() {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ return nil
+}
+
+// One pool that stops answering must not slow placement on the others: its
+// probe hangs for seconds, and used to hold the scheduler loop while it did.
+func TestUnreachablePoolDoesNotStallScheduling(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ _, _, token := h.tenant("acme", api.Quota{})
+ slow := &stuckHealth{Adapter: fake.New(fake.Options{Trust: api.TrustPrivate, TotalCPU: 4, TotalMemoryMiB: 16384})}
+ pool := api.Pool{Name: "slow", Adapter: "fake", Trust: api.TrustPrivate, Enabled: true, Source: "config"}
+ _ = h.store.UpsertPool(context.Background(), pool)
+ h.pools.Set(pool, slow)
+
+ h.sched.Config.Scheduler.IntervalMs = 10
+ h.sched.RefreshInterval = 10 * time.Millisecond
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() { h.sched.Run(ctx); close(done) }()
+ defer func() { cancel(); <-done }()
+
+ time.Sleep(50 * time.Millisecond) // the first, healthy probe pass is over
+ slow.stuck.Store(true)
+ time.Sleep(100 * time.Millisecond) // a probe of the stuck pool is now hanging
+ sb := h.create(token, api.CreateSandboxRequest{})
+ deadline := time.Now().Add(2 * time.Second)
+ for h.get(token, sb.ID).Status != api.StatusRunning {
+ if time.Now().After(deadline) {
+ t.Fatalf("sandbox still %s after 2s while another pool's probe hangs", h.get(token, sb.ID).Status)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 78dd650..a4c0e80 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -47,6 +47,9 @@ type Server struct {
// Secrets seals provider credentials stored through the API; nil means
// none can be stored (env: and file: references still work).
Secrets *secrets.Box
+ // ActivityBeat is how often a call still running in a sandbox counts as
+ // activity.
+ ActivityBeat time.Duration
limiter *limiter
now func() time.Time
@@ -56,8 +59,9 @@ type Server struct {
func New(cfg config.Config, st store.Store, pm *pools.Manager, sch *scheduler.Scheduler, rec *auditlog.Recorder, log *slog.Logger, installation string) *Server {
return &Server{
Config: cfg, Store: st, Pools: pm, Scheduler: sch, Audit: rec, Log: log, Installation: installation,
- limiter: newLimiter(cfg.RateLimit.RequestsPerSecond, cfg.RateLimit.Burst),
- now: func() time.Time { return time.Now().UTC() },
+ ActivityBeat: 15 * time.Second,
+ limiter: newLimiter(cfg.RateLimit.RequestsPerSecond, cfg.RateLimit.Burst),
+ now: func() time.Time { return time.Now().UTC() },
}
}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index fea00fc..dfa7bef 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -17,6 +17,7 @@ import (
"github.com/different-ai/differently/internal/adapters/fake"
"github.com/different-ai/differently/internal/auditlog"
"github.com/different-ai/differently/internal/config"
+ "github.com/different-ai/differently/internal/metrics"
"github.com/different-ai/differently/internal/pools"
"github.com/different-ai/differently/internal/scheduler"
"github.com/different-ai/differently/internal/secrets"
@@ -24,6 +25,8 @@ import (
"github.com/different-ai/differently/internal/store"
"github.com/different-ai/differently/pkg/adapter"
"github.com/different-ai/differently/pkg/api"
+ "github.com/prometheus/client_golang/prometheus"
+ dto "github.com/prometheus/client_model/go"
)
const operatorToken = "dly_bootstrap_0123456789abcdefghjkmnpqrstvwxyz"
@@ -31,6 +34,7 @@ const operatorToken = "dly_bootstrap_0123456789abcdefghjkmnpqrstvwxyz"
type harness struct {
t *testing.T
srv *httptest.Server
+ api *server.Server
sched *scheduler.Scheduler
store *store.Memory
fc *fake.Adapter
@@ -89,6 +93,7 @@ func newHarnessWith(t *testing.T, policy api.PlacementPolicy, tweak func(*config
t.Fatal(err)
}
pm.Refresh(ctx)
+ h.api = srv
h.srv = httptest.NewServer(srv.Handler())
t.Cleanup(h.srv.Close)
return h
@@ -438,6 +443,13 @@ func TestFailureRecoveryAndReconcile(t *testing.T) {
if u.ActiveSandboxes != 0 || u.CPU != 0 {
t.Fatalf("quota leaked: %+v", u)
}
+ // An instance with no sandbox ID was not created by Differently (or the
+ // backend ignored the ownership filter): the reconciler never deletes it.
+ h.fc.Inject("", inst)
+ h.sched.Reconcile(context.Background())
+ if h.fc.Count() != 1 {
+ t.Fatal("the reconciler deleted a backend instance that carries no sandbox ID")
+ }
}
// A configured template reaches each backend as that pool's own image, and
@@ -807,3 +819,129 @@ func TestCapabilitiesFollowTenantPolicy(t *testing.T) {
t.Fatalf("another organization's policy changes nothing here: %v", got)
}
}
+
+// checkpointPool adds a one-slot pool whose pause checkpoints (frees its slot),
+// with an optional preemption policy, and returns a tenant pinned to it.
+func checkpointPool(t *testing.T, h *harness, pre *api.Preemption) (token string, pin *api.Placement) {
+ t.Helper()
+ token, pin, _ = checkpointBackend(t, h, pre)
+ return token, pin
+}
+
+// checkpointBackend is checkpointPool, with the pool's fake backend.
+func checkpointBackend(t *testing.T, h *harness, pre *api.Preemption) (string, *api.Placement, *fake.Adapter) {
+ t.Helper()
+ features := append(slices.Clone(fake.DefaultFeatures), adapter.FeaturePauseCheckpoint)
+ a := fake.New(fake.Options{Trust: api.TrustPrivate, Features: features})
+ pool := api.Pool{Name: "ckpt", Adapter: "fake", Trust: api.TrustPrivate, Enabled: true, Region: "dc1", MaxConcurrency: 1, Source: "config", Preemption: pre}
+ ctx := context.Background()
+ _ = h.store.UpsertPool(ctx, pool)
+ h.pools.Set(pool, a)
+ h.pools.Refresh(ctx)
+ _, _, token := h.tenant("ckpt-"+t.Name(), api.Quota{})
+ return token, &api.Placement{AllowedPools: []string{"ckpt"}}, a
+}
+
+// pauseCount is how many pauses differently_pause_seconds has timed on pool.
+func pauseCount(t *testing.T, pool string) uint64 {
+ t.Helper()
+ var m dto.Metric
+ if err := metrics.PauseSeconds.WithLabelValues(pool).(prometheus.Metric).Write(&m); err != nil {
+ t.Fatal(err)
+ }
+ return m.GetHistogram().GetSampleCount()
+}
+
+// A paused sandbox on a checkpointing pool gives its slot back; resuming on
+// the full pool queues (202), and the queue resumes it once the slot frees.
+func TestPausedSandboxGivesItsSlotBack(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin := checkpointPool(t, h, nil)
+ a := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ pauses := pauseCount(t, "ckpt")
+ h.must(200, "POST", "/v1/sandboxes/"+a.ID+"/pause", token, nil, nil)
+ if got := h.get(token, a.ID); got.Status != api.StatusPaused || !got.SlotReleased || got.PausedAt == nil {
+ t.Fatalf("pause should release the slot: %+v", got)
+ }
+ if got := pauseCount(t, "ckpt"); got != pauses+1 {
+ t.Fatalf("differently_pause_seconds should time the pause: %d pauses before, %d after", pauses, got)
+ }
+ b := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ if got := h.get(token, b.ID); got.Status != api.StatusRunning {
+ t.Fatalf("the freed slot should take a new sandbox: %+v", got)
+ }
+ var queued api.Sandbox
+ h.must(202, "POST", "/v1/sandboxes/"+a.ID+"/resume", token, nil, &queued)
+ if queued.Status != api.StatusPaused || queued.ResumeRequestedAt == nil || !strings.Contains(queued.StatusReason, "waiting for a free slot") {
+ t.Fatalf("resume on a full pool should queue: %+v", queued)
+ }
+ h.tick()
+ if got := h.get(token, a.ID); got.Status != api.StatusPaused {
+ t.Fatalf("still full, still queued: %+v", got)
+ }
+ h.must(202, "DELETE", "/v1/sandboxes/"+b.ID, token, nil, nil)
+ h.tick()
+ got := h.get(token, a.ID)
+ if got.Status != api.StatusRunning || got.ResumeRequestedAt != nil || got.SlotReleased {
+ t.Fatalf("the queue should resume it once the slot frees: %+v", got)
+ }
+}
+
+// With a preemption policy, waiting work makes the scheduler pause the least
+// recently used running sandbox; a non-preemptible one is never chosen.
+func TestPreemptionMakesRoom(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin := checkpointPool(t, h, &api.Preemption{Mode: api.PreemptLRU, MinRunSeconds: 60})
+ no := false
+ guarded := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin, Preemptible: &no})
+ h.tick()
+ later := time.Now().UTC().Add(5 * time.Minute)
+ h.sched.Now = func() time.Time { return later }
+ waiting := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin})
+ h.tick()
+ if got := h.get(token, waiting.ID); got.Status != api.StatusQueued {
+ t.Fatalf("a non-preemptible sandbox keeps its slot: %+v", got)
+ }
+
+ h.must(200, "PATCH", "/v1/sandboxes/"+guarded.ID, token, api.UpdateSandboxRequest{Preemptible: ptrBool(true)}, nil)
+ h.tick() // the waiting create triggers the preemption
+ h.sched.Drain()
+ victim := h.get(token, guarded.ID)
+ if victim.Status != api.StatusPaused || !victim.SlotReleased || victim.IdledAt == nil || !strings.Contains(victim.StatusReason, "make room") {
+ t.Fatalf("the running sandbox should be preempted: %+v", victim)
+ }
+ h.tick()
+ if got := h.get(token, waiting.ID); got.Status != api.StatusRunning {
+ t.Fatalf("the waiting create should take the freed slot: %+v", got)
+ }
+}
+
+// maxRunSeconds pauses a long runner; maxPausedSeconds deletes a sandbox left
+// paused.
+func TestLifecycleLimits(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ token, pin := checkpointPool(t, h, nil)
+ sb := h.create(token, api.CreateSandboxRequest{Profile: "small", Placement: pin, MaxRunSeconds: 60, MaxPausedSeconds: 120})
+ h.tick()
+ if got := h.get(token, sb.ID); got.MaxRunSeconds != 60 || got.MaxPausedSeconds != 120 || !got.Preemptible {
+ t.Fatalf("limits not recorded: %+v", got)
+ }
+ start := time.Now().UTC()
+ h.sched.Now = func() time.Time { return start.Add(61 * time.Second) }
+ h.sched.EnforceLifecycle(context.Background())
+ if got := h.get(token, sb.ID); got.Status != api.StatusPaused || !strings.Contains(got.StatusReason, "maxRunSeconds") {
+ t.Fatalf("maxRunSeconds should pause it: %+v", got)
+ }
+ h.sched.Now = func() time.Time { return start.Add(61*time.Second + 121*time.Second) }
+ h.sched.EnforceLifecycle(context.Background())
+ if got := h.get(token, sb.ID); got.Status != api.StatusTerminating && got.Status != api.StatusTerminated {
+ t.Fatalf("maxPausedSeconds should delete it: %+v", got)
+ }
+ if code := h.call("POST", "/v1/sandboxes", token, api.CreateSandboxRequest{Profile: "small", MaxRunSeconds: 10}, nil); code != 400 {
+ t.Fatalf("maxRunSeconds under 60 should be refused, got %d", code)
+ }
+}
+
+func ptrBool(b bool) *bool { return &b }
diff --git a/internal/server/tenancy.go b/internal/server/tenancy.go
index 5ea1b95..7faa440 100644
--- a/internal/server/tenancy.go
+++ b/internal/server/tenancy.go
@@ -417,7 +417,16 @@ func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p auth.Princi
if err := p.Require(auth.ScopeAuditRead); err != nil {
return err
}
- f := store.AuditFilter{OrgID: p.OrgID, Resource: r.URL.Query().Get("resource"), Action: r.URL.Query().Get("action"), AfterSeq: int64(queryInt(r, "afterSeq", 0)), Limit: min(max(queryInt(r, "limit", 100), 1), 1000)}
+ f := store.AuditFilter{OrgID: p.OrgID, Resource: r.URL.Query().Get("resource"), Action: r.URL.Query().Get("action"), AfterSeq: int64(queryInt(r, "afterSeq", 0)), BeforeSeq: int64(queryInt(r, "beforeSeq", 0)), Limit: min(max(queryInt(r, "limit", 100), 1), 1000)}
+ // order=desc returns the newest events first, so one page is the latest
+ // activity; its nextCursor is the beforeSeq of the next, older page.
+ switch r.URL.Query().Get("order") {
+ case "", "asc":
+ case "desc":
+ f.Desc = true
+ default:
+ return api.Errorf(api.CodeInvalidRequest, "order must be asc or desc")
+ }
if p.Operator() {
f.OrgID = r.URL.Query().Get("orgId")
}
@@ -540,7 +549,11 @@ func (s *Server) capabilities(w http.ResponseWriter, r *http.Request, p auth.Pri
var eff *policy.Effective
if p.OrgID != "" {
class, _ := s.Config.Classification(s.Config.Defaults.Classification)
- e := policy.Merge(policy.Input{Policies: s.Scheduler.Policies(r.Context(), p.OrgID, p.ProjectID), Classification: class})
+ policies, err := s.Scheduler.Policies(r.Context(), p.OrgID, p.ProjectID)
+ if err != nil {
+ return s.placementUnavailable(err)
+ }
+ e := policy.Merge(policy.Input{Policies: policies, Classification: class})
eff = &e
}
active, _ := s.Store.PoolActive(r.Context())
diff --git a/internal/server/ttl_validation_test.go b/internal/server/ttl_validation_test.go
new file mode 100644
index 0000000..4ba9a2a
--- /dev/null
+++ b/internal/server/ttl_validation_test.go
@@ -0,0 +1,35 @@
+package server_test
+
+import (
+ "testing"
+
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// A negative ttlSeconds used to create a sandbox that never expires, under any
+// maxTTLSeconds quota, and a PATCH to 0 expired a running sandbox at once.
+func TestInvalidTTLIsRejected(t *testing.T) {
+ h := newHarness(t, api.PlacementPolicy{})
+ _, _, token := h.tenant("acme", api.Quota{MaxTTLSeconds: 600})
+ for _, ttl := range []int{-1, -3600} {
+ var env api.ErrorEnvelope
+ if code := h.call("POST", "/v1/sandboxes", token, api.CreateSandboxRequest{TTLSeconds: ttl}, &env); code != 400 || env.Error == nil || env.Error.Code != api.CodeInvalidRequest {
+ t.Fatalf("create with ttlSeconds %d: status %d, body %+v", ttl, code, env.Error)
+ }
+ }
+ sb := h.create(token, api.CreateSandboxRequest{TTLSeconds: 300})
+ h.tick()
+ for _, ttl := range []int{0, -1} {
+ if code := h.call("PATCH", "/v1/sandboxes/"+sb.ID, token, map[string]int{"ttlSeconds": ttl}, nil); code != 400 {
+ t.Fatalf("update to ttlSeconds %d: status %d, want 400", ttl, code)
+ }
+ }
+ h.tick()
+ if got := h.get(token, sb.ID); got.Status != api.StatusRunning || got.TTLSeconds != 300 {
+ t.Fatalf("a rejected TTL update changed the sandbox: %+v", got)
+ }
+ h.must(200, "PATCH", "/v1/sandboxes/"+sb.ID, token, map[string]int{"ttlSeconds": 120}, nil)
+ if got := h.get(token, sb.ID); got.TTLSeconds != 120 {
+ t.Fatalf("a valid TTL update was not applied: %+v", got)
+ }
+}
diff --git a/internal/store/memory.go b/internal/store/memory.go
index 371144b..47a1807 100644
--- a/internal/store/memory.go
+++ b/internal/store/memory.go
@@ -454,12 +454,12 @@ func (m *Memory) ActivateSandbox(_ context.Context, sid string, act Activation)
if act.MaxConcurrency > 0 {
n := 0
for _, o := range m.sandboxes {
- if o.Pool == act.Pool && HoldsPoolSlot(o.Status) {
+ if o.Pool == act.Pool && HoldsPoolSlot(o) {
n++
}
}
if n >= act.MaxConcurrency {
- return s, &api.Error{Code: "insufficient_capacity", Message: "pool " + act.Pool + " is at its concurrency limit"}
+ return s, InsufficientCapacity(act.Pool)
}
}
s = clone(s)
@@ -502,6 +502,33 @@ func (m *Memory) ReserveCompute(_ context.Context, sid string, from, to api.Sand
return s, nil
}
+func (m *Memory) ReclaimSlot(_ context.Context, sid string, maxConcurrency int, now time.Time) (SandboxRecord, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ s, ok := m.sandboxes[sid]
+ if !ok {
+ return s, NotFound("sandbox", sid)
+ }
+ if !s.SlotReleased {
+ return clone(s), nil
+ }
+ if maxConcurrency > 0 {
+ n := 0
+ for _, o := range m.sandboxes {
+ if o.Pool == s.Pool && HoldsPoolSlot(o) {
+ n++
+ }
+ }
+ if n >= maxConcurrency {
+ return clone(s), InsufficientCapacity(s.Pool)
+ }
+ }
+ s = clone(s)
+ s.SlotReleased, s.UpdatedAt = false, now
+ m.sandboxes[sid] = clone(s)
+ return s, nil
+}
+
func (m *Memory) Usage(_ context.Context, orgID, projectID string) (api.QuotaUsage, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -513,7 +540,7 @@ func (m *Memory) PoolActive(context.Context) (map[string]int, error) {
defer m.mu.Unlock()
out := map[string]int{}
for _, s := range m.sandboxes {
- if s.Pool != "" && HoldsPoolSlot(s.Status) {
+ if s.Pool != "" && HoldsPoolSlot(s) {
out[s.Pool]++
}
}
@@ -577,8 +604,13 @@ func (m *Memory) ListAudit(_ context.Context, f AuditFilter) ([]api.AuditEvent,
m.mu.Lock()
defer m.mu.Unlock()
var out []api.AuditEvent
- for _, ev := range m.audit {
+ for i := range m.audit {
+ ev := m.audit[i]
+ if f.Desc {
+ ev = m.audit[len(m.audit)-1-i]
+ }
if ev.Seq <= f.AfterSeq ||
+ (f.BeforeSeq > 0 && ev.Seq >= f.BeforeSeq) ||
(f.OrgID != "" && ev.OrgID != f.OrgID) ||
(f.Resource != "" && ev.Resource != f.Resource) ||
(f.Action != "" && ev.Action != f.Action) {
diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go
index 5d4f3ca..2f7b50d 100644
--- a/internal/store/postgres/postgres.go
+++ b/internal/store/postgres/postgres.go
@@ -569,7 +569,7 @@ func (s *Store) ActivateSandbox(ctx context.Context, sid string, act store.Activ
}
if act.MaxConcurrency > 0 {
var n int
- if err := tx.QueryRow(ctx, "SELECT count(*) FROM sandboxes WHERE pool = $1 AND status IN ('provisioning','running','paused','stopped','terminating')", act.Pool).Scan(&n); err != nil {
+ if err := tx.QueryRow(ctx, "SELECT count(*) FROM sandboxes WHERE pool = $1 AND "+store.SlotHolders, act.Pool).Scan(&n); err != nil {
return err
}
if n >= act.MaxConcurrency {
@@ -634,8 +634,47 @@ func (s *Store) Usage(ctx context.Context, orgID, projectID string) (api.QuotaUs
return tally(ctx, s.pool, orgID, projectID)
}
+func (s *Store) ReclaimSlot(ctx context.Context, sid string, maxConcurrency int, now time.Time) (store.SandboxRecord, error) {
+ var out store.SandboxRecord
+ err := s.tx(ctx, func(tx pgx.Tx) error {
+ var pool string
+ if err := tx.QueryRow(ctx, "SELECT pool FROM sandboxes WHERE id = $1", sid).Scan(&pool); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return store.NotFound("sandbox", sid)
+ }
+ return err
+ }
+ // Lock order everywhere: organization, then pool, then sandbox. Only
+ // the pool's row serializes concurrency checks here.
+ if _, err := tx.Exec(ctx, "SELECT 1 FROM pools WHERE name = $1 FOR UPDATE", pool); err != nil {
+ return err
+ }
+ r, err := getDoc[store.SandboxRecord](ctx, tx, "sandbox", sid, "SELECT doc FROM sandboxes WHERE id = $1 FOR UPDATE", sid)
+ if err != nil {
+ return err
+ }
+ out = r
+ if !r.SlotReleased {
+ return nil
+ }
+ if maxConcurrency > 0 {
+ var n int
+ if err := tx.QueryRow(ctx, "SELECT count(*) FROM sandboxes WHERE pool = $1 AND "+store.SlotHolders, pool).Scan(&n); err != nil {
+ return err
+ }
+ if n >= maxConcurrency {
+ return store.InsufficientCapacity(pool)
+ }
+ }
+ r.SlotReleased, r.UpdatedAt = false, now
+ out = r
+ return saveSandbox(ctx, tx, r)
+ })
+ return out, err
+}
+
func (s *Store) PoolActive(ctx context.Context) (map[string]int, error) {
- rows, err := s.pool.Query(ctx, "SELECT pool, count(*) FROM sandboxes WHERE pool <> '' AND status IN ('provisioning','running','paused','stopped','terminating') GROUP BY pool")
+ rows, err := s.pool.Query(ctx, "SELECT pool, count(*) FROM sandboxes WHERE pool <> '' AND "+store.SlotHolders+" GROUP BY pool")
if err != nil {
return nil, err
}
@@ -698,9 +737,13 @@ func (s *Store) ListAudit(ctx context.Context, f store.AuditFilter) ([]api.Audit
if limit <= 0 {
limit = 1 << 30
}
+ order := "ORDER BY seq"
+ if f.Desc {
+ order = "ORDER BY seq DESC"
+ }
return listDocs[api.AuditEvent](ctx, s.pool, `SELECT doc FROM audit_events
- WHERE seq > $1 AND ($2 = '' OR org_id = $2) AND ($3 = '' OR resource = $3) AND ($4 = '' OR action = $4)
- ORDER BY seq LIMIT $5`, f.AfterSeq, f.OrgID, f.Resource, f.Action, limit)
+ WHERE seq > $1 AND ($6 <= 0 OR seq < $6) AND ($2 = '' OR org_id = $2) AND ($3 = '' OR resource = $3) AND ($4 = '' OR action = $4)
+ `+order+` LIMIT $5`, f.AfterSeq, f.OrgID, f.Resource, f.Action, limit, f.BeforeSeq)
}
// TryLeader holds a session advisory lock on a dedicated connection. If the
diff --git a/internal/store/store.go b/internal/store/store.go
index 5ba736d..1bfb6c8 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -89,7 +89,11 @@ type AuditFilter struct {
Resource string
Action string
AfterSeq int64
- Limit int
+ // BeforeSeq, when positive, keeps only events with a lower sequence.
+ BeforeSeq int64
+ // Desc returns the newest events first, so a limit keeps the latest.
+ Desc bool
+ Limit int
}
// Admission is the input to AdmitSandbox.
@@ -172,6 +176,11 @@ type Store interface {
Usage(ctx context.Context, orgID, projectID string) (api.QuotaUsage, error)
// PoolActive counts sandboxes holding a slot on each pool.
PoolActive(ctx context.Context) (map[string]int, error)
+ // ReclaimSlot gives a paused sandbox whose slot was released its slot back,
+ // atomically with the pool's concurrency check (maxConcurrency 0: no
+ // limit). A full pool answers insufficient_capacity and changes nothing;
+ // a sandbox that holds its slot already is returned unchanged.
+ ReclaimSlot(ctx context.Context, id string, maxConcurrency int, now time.Time) (SandboxRecord, error)
CreateSnapshot(ctx context.Context, s SnapshotRecord) error
GetSnapshot(ctx context.Context, id string) (SnapshotRecord, error)
@@ -187,15 +196,22 @@ type Store interface {
Close() error
}
-// HoldsPoolSlot reports whether a sandbox occupies pool concurrency.
-func HoldsPoolSlot(s api.SandboxStatus) bool {
- switch s {
- case api.StatusProvisioning, api.StatusRunning, api.StatusPaused, api.StatusStopped, api.StatusTerminating:
+// HoldsPoolSlot reports whether a sandbox occupies pool concurrency. A paused
+// sandbox on a pool that checkpoints on pause has given its slot back
+// (SlotReleased) until it resumes.
+func HoldsPoolSlot(s SandboxRecord) bool {
+ switch s.Status {
+ case api.StatusProvisioning, api.StatusRunning, api.StatusPausing, api.StatusStopped, api.StatusTerminating:
return true
+ case api.StatusPaused:
+ return !s.SlotReleased
}
return false
}
+// SlotHolders is the SQL condition matching HoldsPoolSlot.
+const SlotHolders = `(status IN ('provisioning','running','pausing','stopped','terminating') OR (status = 'paused' AND NOT COALESCE((doc->>'slotReleased')::boolean, false)))`
+
// Tally computes quota usage from records.
func Tally(recs []SandboxRecord, snapshots int) api.QuotaUsage {
var u api.QuotaUsage
@@ -305,6 +321,11 @@ func FairOrder(queued []SandboxRecord, activeCPUByOrg map[string]int) []SandboxR
return out
}
+// InsufficientCapacity is the error for a pool at its concurrency limit.
+func InsufficientCapacity(pool string) error {
+ return &api.Error{Code: "insufficient_capacity", Message: "pool " + pool + " is at its concurrency limit"}
+}
+
// NotFound builds a not_found error for a resource kind.
func NotFound(kind, id string) error {
return api.Errorf(api.CodeNotFound, "%s %s not found", kind, id)
diff --git a/internal/store/storetest/storetest.go b/internal/store/storetest/storetest.go
index fba35af..150fb3c 100644
--- a/internal/store/storetest/storetest.go
+++ b/internal/store/storetest/storetest.go
@@ -3,6 +3,7 @@ package storetest
import (
"context"
+ "slices"
"sync"
"sync/atomic"
"testing"
@@ -19,6 +20,7 @@ func Run(t *testing.T, newStore func(t *testing.T) store.Store) {
t.Run("ConcurrentAdmissionRespectsQueueLimit", func(t *testing.T) { concurrentAdmission(t, newStore(t)) })
t.Run("ConcurrentActivationRespectsQuota", func(t *testing.T) { concurrentActivation(t, newStore(t)) })
t.Run("PoolConcurrencyCap", func(t *testing.T) { poolCap(t, newStore(t)) })
+ t.Run("ReleasedSlotsAndReclaim", func(t *testing.T) { releasedSlots(t, newStore(t)) })
t.Run("Idempotency", func(t *testing.T) { idempotency(t, newStore(t)) })
t.Run("Secrets", func(t *testing.T) { secrets(t, newStore(t)) })
t.Run("FairOrderAcrossOrgs", func(t *testing.T) { fairOrder(t, newStore(t)) })
@@ -149,6 +151,76 @@ func poolCap(t *testing.T, s store.Store) {
}
}
+// releasedSlots: a paused sandbox that released its slot does not count
+// toward the pool's cap, and two concurrent resumes racing for the one free
+// slot get it exactly once.
+func releasedSlots(t *testing.T, s store.Store) {
+ ctx := context.Background()
+ _, p := seed(t, s, "org-rs")
+ // The server writes a row for every pool at startup; Postgres serializes
+ // a pool's slots by locking it, so without one the reclaims below race.
+ if err := s.UpsertPool(ctx, api.Pool{Name: "ck", Adapter: "fake"}); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC()
+ var ids []string
+ for i := 0; i < 3; i++ {
+ rec, _, err := s.AdmitSandbox(ctx, sandbox(p, 1), store.Admission{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids = append(ids, rec.ID)
+ }
+ for _, sid := range ids[:2] {
+ if _, err := s.ActivateSandbox(ctx, sid, store.Activation{Pool: "ck", MaxConcurrency: 2, Now: now}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.UpdateSandbox(ctx, sid, func(r *store.SandboxRecord) error {
+ r.Status, r.SlotReleased = api.StatusPaused, true
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := s.ActivateSandbox(ctx, ids[2], store.Activation{Pool: "ck", MaxConcurrency: 2, Now: now}); err != nil {
+ t.Fatalf("released slots should not count: %v", err)
+ }
+ if counts, _ := s.PoolActive(ctx); counts["ck"] != 1 {
+ t.Fatalf("pool active %v, want 1", counts)
+ }
+ // A pause in progress has not given its slot back yet.
+ if _, err := s.UpdateSandbox(ctx, ids[2], func(r *store.SandboxRecord) error {
+ r.Status = api.StatusPausing
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if counts, _ := s.PoolActive(ctx); counts["ck"] != 1 {
+ t.Fatalf("pool active %v with one pausing, want 1", counts)
+ }
+ var ok atomic.Int32
+ var wg sync.WaitGroup
+ for _, sid := range ids[:2] {
+ wg.Add(1)
+ go func(sid string) {
+ defer wg.Done()
+ _, err := s.ReclaimSlot(ctx, sid, 2, now)
+ if err == nil {
+ ok.Add(1)
+ } else if !api.IsCode(err, "insufficient_capacity") {
+ t.Errorf("unexpected %v", err)
+ }
+ }(sid)
+ }
+ wg.Wait()
+ if ok.Load() != 1 {
+ t.Fatalf("reclaimed %d slots, want 1", ok.Load())
+ }
+ if counts, _ := s.PoolActive(ctx); counts["ck"] != 2 {
+ t.Fatalf("pool active %v, want 2", counts)
+ }
+}
+
func idempotency(t *testing.T, s store.Store) {
ctx := context.Background()
_, p := seed(t, s, "org-d")
@@ -253,6 +325,30 @@ func auditChain(t *testing.T, s store.Store) {
if res := audit.Verify(events); !res.OK || res.Events != 5 {
t.Fatalf("chain %+v", res)
}
+ // Newest first: a limit keeps the latest events, and BeforeSeq pages back.
+ seqs := func(f store.AuditFilter) (out []int64) {
+ t.Helper()
+ list, err := s.ListAudit(ctx, f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, ev := range list {
+ out = append(out, ev.Seq)
+ }
+ return out
+ }
+ if got := seqs(store.AuditFilter{Desc: true, Limit: 2}); !slices.Equal(got, []int64{5, 4}) {
+ t.Fatalf("newest two events: %v, want [5 4]", got)
+ }
+ if got := seqs(store.AuditFilter{Desc: true, Limit: 2, BeforeSeq: 4}); !slices.Equal(got, []int64{3, 2}) {
+ t.Fatalf("the page before 4: %v, want [3 2]", got)
+ }
+ if got := seqs(store.AuditFilter{AfterSeq: 1, BeforeSeq: 4}); !slices.Equal(got, []int64{2, 3}) {
+ t.Fatalf("between 1 and 4, oldest first: %v, want [2 3]", got)
+ }
+ if got := seqs(store.AuditFilter{Limit: 2}); !slices.Equal(got, []int64{1, 2}) {
+ t.Fatalf("default order: %v, want [1 2]", got)
+ }
}
func concurrentStart(t *testing.T, s store.Store) {
diff --git a/internal/wiring/wiring.go b/internal/wiring/wiring.go
index 76afd7d..c60d175 100644
--- a/internal/wiring/wiring.go
+++ b/internal/wiring/wiring.go
@@ -1,28 +1,20 @@
// Package wiring registers the built-in adapter kinds with the pool manager.
+//
+// The default build holds the kinds that have run against real backends
+// (opensandbox, daytona, freestyle), the remote plugin protocol, and the two
+// dev-mode kinds (devhost, fake). The rest are experimental: they compile in
+// only with the experimental_adapters build tag (wiring_experimental.go).
package wiring
import (
"fmt"
"path/filepath"
- "github.com/different-ai/differently/internal/adapters/agentcore"
- "github.com/different-ai/differently/internal/adapters/agentsandbox"
- "github.com/different-ai/differently/internal/adapters/azuresandboxes"
- "github.com/different-ai/differently/internal/adapters/azuresessions"
- "github.com/different-ai/differently/internal/adapters/blaxel"
- "github.com/different-ai/differently/internal/adapters/boat"
- "github.com/different-ai/differently/internal/adapters/cubesandbox"
"github.com/different-ai/differently/internal/adapters/daytona"
"github.com/different-ai/differently/internal/adapters/devhost"
- "github.com/different-ai/differently/internal/adapters/e2b"
"github.com/different-ai/differently/internal/adapters/fake"
- "github.com/different-ai/differently/internal/adapters/fly"
"github.com/different-ai/differently/internal/adapters/freestyle"
- "github.com/different-ai/differently/internal/adapters/gcpsandboxes"
- "github.com/different-ai/differently/internal/adapters/morph"
- "github.com/different-ai/differently/internal/adapters/northflank"
"github.com/different-ai/differently/internal/adapters/opensandbox"
- "github.com/different-ai/differently/internal/adapters/vercel"
"github.com/different-ai/differently/internal/config"
"github.com/different-ai/differently/internal/pools"
"github.com/different-ai/differently/pkg/adapter"
@@ -30,8 +22,9 @@ import (
"github.com/different-ai/differently/pkg/api"
)
-// Register installs every built-in adapter factory.
+// Register installs the adapter factories this binary was built with.
func Register() {
+ registerExperimental()
pools.Register("remote", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
token, err := resolve(p, d)
if err != nil {
@@ -53,137 +46,6 @@ func Register() {
}
return freestyle.New(freestyle.FromConfig(p.Config, key)), nil
})
- pools.Register("vercel", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- key, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := vercel.FromConfig(p.Config, key)
- if err != nil {
- return nil, err
- }
- return vercel.New(opts), nil
- })
- pools.Register("fly", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- key, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := fly.FromConfig(p.Config, key)
- if err != nil {
- return nil, err
- }
- return fly.New(opts), nil
- })
- pools.Register("blaxel", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- key, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := blaxel.FromConfig(p.Config, key)
- if err != nil {
- return nil, err
- }
- return blaxel.New(opts), nil
- })
- pools.Register("boat", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- key, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- return boat.New(boat.FromConfig(p.Config, key)), nil
- })
- pools.Register("e2b", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- key, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- return e2b.New(e2b.FromConfig(p.Config, p.Trust, key)), nil
- })
- pools.Register("morph", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- key, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- return morph.New(morph.FromConfig(p.Config, key)), nil
- })
- pools.Register("aws-agentcore", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- cred, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := agentcore.FromConfig(p.Config, p.Region, cred)
- if err != nil {
- return nil, err
- }
- return agentcore.New(opts), nil
- })
- pools.Register("azure-sandboxes", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- cred, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := azuresandboxes.FromConfig(p.Config, p.Region, cred)
- if err != nil {
- return nil, err
- }
- return azuresandboxes.New(opts), nil
- })
- pools.Register("azure-sessions", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- cred, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := azuresessions.FromConfig(p.Config, p.Region, cred)
- if err != nil {
- return nil, err
- }
- return azuresessions.New(opts), nil
- })
- pools.Register("gcp-sandboxes", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- cred, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := gcpsandboxes.FromConfig(p.Config, p.Region, cred)
- if err != nil {
- return nil, err
- }
- return gcpsandboxes.New(opts), nil
- })
- pools.Register("northflank", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- token, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := northflank.FromConfig(p.Config, token)
- if err != nil {
- return nil, err
- }
- return northflank.New(opts), nil
- })
- pools.Register("agent-sandbox", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- token, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := agentsandbox.FromConfig(p.Config, p.Trust, token)
- if err != nil {
- return nil, err
- }
- return agentsandbox.New(opts)
- })
- pools.Register("cubesandbox", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
- key, err := resolve(p, d)
- if err != nil {
- return nil, err
- }
- opts, err := cubesandbox.FromConfig(p.Config, p.Trust, key)
- if err != nil {
- return nil, err
- }
- return cubesandbox.New(opts)
- })
pools.Register("opensandbox", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
key, err := resolve(p, d)
if err != nil {
diff --git a/internal/wiring/wiring_default.go b/internal/wiring/wiring_default.go
new file mode 100644
index 0000000..66366d0
--- /dev/null
+++ b/internal/wiring/wiring_default.go
@@ -0,0 +1,7 @@
+//go:build !experimental_adapters
+
+package wiring
+
+// registerExperimental is empty in the default build: the experimental
+// adapter kinds are compiled in only with -tags experimental_adapters.
+func registerExperimental() {}
diff --git a/internal/wiring/wiring_experimental.go b/internal/wiring/wiring_experimental.go
new file mode 100644
index 0000000..efc6d4c
--- /dev/null
+++ b/internal/wiring/wiring_experimental.go
@@ -0,0 +1,160 @@
+//go:build experimental_adapters
+
+package wiring
+
+import (
+ "github.com/different-ai/differently/internal/adapters/agentcore"
+ "github.com/different-ai/differently/internal/adapters/agentsandbox"
+ "github.com/different-ai/differently/internal/adapters/azuresandboxes"
+ "github.com/different-ai/differently/internal/adapters/azuresessions"
+ "github.com/different-ai/differently/internal/adapters/blaxel"
+ "github.com/different-ai/differently/internal/adapters/boat"
+ "github.com/different-ai/differently/internal/adapters/cubesandbox"
+ "github.com/different-ai/differently/internal/adapters/e2b"
+ "github.com/different-ai/differently/internal/adapters/fly"
+ "github.com/different-ai/differently/internal/adapters/gcpsandboxes"
+ "github.com/different-ai/differently/internal/adapters/morph"
+ "github.com/different-ai/differently/internal/adapters/northflank"
+ "github.com/different-ai/differently/internal/adapters/vercel"
+ "github.com/different-ai/differently/internal/pools"
+ "github.com/different-ai/differently/pkg/adapter"
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// registerExperimental installs the adapter kinds listed in
+// config.ExperimentalAdapters. Apart from agent-sandbox, which ran live
+// against a kind cluster, they are tested only against fakes of each
+// provider's published API, so they stay out of the default build.
+func registerExperimental() {
+ pools.Register("vercel", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ key, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := vercel.FromConfig(p.Config, key)
+ if err != nil {
+ return nil, err
+ }
+ return vercel.New(opts), nil
+ })
+ pools.Register("fly", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ key, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := fly.FromConfig(p.Config, key)
+ if err != nil {
+ return nil, err
+ }
+ return fly.New(opts), nil
+ })
+ pools.Register("blaxel", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ key, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := blaxel.FromConfig(p.Config, key)
+ if err != nil {
+ return nil, err
+ }
+ return blaxel.New(opts), nil
+ })
+ pools.Register("boat", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ key, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ return boat.New(boat.FromConfig(p.Config, key)), nil
+ })
+ pools.Register("e2b", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ key, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ return e2b.New(e2b.FromConfig(p.Config, p.Trust, key)), nil
+ })
+ pools.Register("morph", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ key, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ return morph.New(morph.FromConfig(p.Config, key)), nil
+ })
+ pools.Register("aws-agentcore", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ cred, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := agentcore.FromConfig(p.Config, p.Region, cred)
+ if err != nil {
+ return nil, err
+ }
+ return agentcore.New(opts), nil
+ })
+ pools.Register("azure-sandboxes", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ cred, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := azuresandboxes.FromConfig(p.Config, p.Region, cred)
+ if err != nil {
+ return nil, err
+ }
+ return azuresandboxes.New(opts), nil
+ })
+ pools.Register("azure-sessions", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ cred, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := azuresessions.FromConfig(p.Config, p.Region, cred)
+ if err != nil {
+ return nil, err
+ }
+ return azuresessions.New(opts), nil
+ })
+ pools.Register("gcp-sandboxes", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ cred, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := gcpsandboxes.FromConfig(p.Config, p.Region, cred)
+ if err != nil {
+ return nil, err
+ }
+ return gcpsandboxes.New(opts), nil
+ })
+ pools.Register("northflank", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ token, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := northflank.FromConfig(p.Config, token)
+ if err != nil {
+ return nil, err
+ }
+ return northflank.New(opts), nil
+ })
+ pools.Register("agent-sandbox", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ token, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := agentsandbox.FromConfig(p.Config, p.Trust, token)
+ if err != nil {
+ return nil, err
+ }
+ return agentsandbox.New(opts)
+ })
+ pools.Register("cubesandbox", func(p api.Pool, d pools.Deps) (adapter.ExecutionAdapter, error) {
+ key, err := resolve(p, d)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := cubesandbox.FromConfig(p.Config, p.Trust, key)
+ if err != nil {
+ return nil, err
+ }
+ return cubesandbox.New(opts)
+ })
+}
diff --git a/internal/wiring/wiring_test.go b/internal/wiring/wiring_test.go
new file mode 100644
index 0000000..04385a7
--- /dev/null
+++ b/internal/wiring/wiring_test.go
@@ -0,0 +1,22 @@
+package wiring
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/different-ai/differently/internal/config"
+ "github.com/different-ai/differently/internal/pools"
+)
+
+// The kinds config accepts and the kinds with a factory are the same set in
+// every build, so a config never passes validation for a kind the binary
+// cannot build, with or without the experimental_adapters tag.
+func TestRegisteredKindsMatchConfig(t *testing.T) {
+ Register()
+ got, want := pools.Kinds(), slices.Clone(config.KnownAdapters)
+ slices.Sort(got)
+ slices.Sort(want)
+ if !slices.Equal(got, want) {
+ t.Fatalf("registered kinds %v, config.KnownAdapters %v (experimental built: %v)", got, want, config.ExperimentalBuilt())
+ }
+}
diff --git a/pkg/adapter/adapter.go b/pkg/adapter/adapter.go
index 61b90e0..a13c97e 100644
--- a/pkg/adapter/adapter.go
+++ b/pkg/adapter/adapter.go
@@ -28,25 +28,30 @@ const ProtocolVersion = "1"
// Feature names advertised in Capabilities.Features and requested through
// CreateSandboxRequest.Capabilities.
const (
- FeatureExec = "exec"
- FeatureExecStream = "exec.stream"
- FeatureFiles = "files"
- FeatureStopStart = "lifecycle.stop-start"
- FeaturePauseResume = "lifecycle.pause-resume"
- FeatureSnapshotDisk = "snapshots.disk"
- FeatureSnapshotMem = "snapshots.memory"
- FeatureFork = "fork"
- FeaturePersistDisk = "persistence.disk"
- FeaturePorts = "ports.expose"
- FeatureSSH = "ssh"
- FeaturePTY = "pty"
- FeatureEgressControl = "network.egress-control"
- FeatureGPU = "gpu"
- FeatureArchAMD64 = "arch.amd64"
- FeatureArchARM64 = "arch.arm64"
- FeatureMicroVM = "isolation.microvm"
- FeatureContainer = "isolation.container"
- FeatureNoIsolation = "isolation.none"
+ FeatureExec = "exec"
+ FeatureExecStream = "exec.stream"
+ FeatureFiles = "files"
+ FeatureStopStart = "lifecycle.stop-start"
+ FeaturePauseResume = "lifecycle.pause-resume"
+ // FeaturePauseCheckpoint: a pause writes the sandbox (memory included)
+ // out of the host and frees its share of the pool, as Fast Sandbox and
+ // Freestyle do. A paused sandbox on such a pool holds no slot; resuming
+ // takes one again, waiting in the queue if the pool is full.
+ FeaturePauseCheckpoint = "lifecycle.pause-checkpoint"
+ FeatureSnapshotDisk = "snapshots.disk"
+ FeatureSnapshotMem = "snapshots.memory"
+ FeatureFork = "fork"
+ FeaturePersistDisk = "persistence.disk"
+ FeaturePorts = "ports.expose"
+ FeatureSSH = "ssh"
+ FeaturePTY = "pty"
+ FeatureEgressControl = "network.egress-control"
+ FeatureGPU = "gpu"
+ FeatureArchAMD64 = "arch.amd64"
+ FeatureArchARM64 = "arch.arm64"
+ FeatureMicroVM = "isolation.microvm"
+ FeatureContainer = "isolation.container"
+ FeatureNoIsolation = "isolation.none"
)
// Well-known labels adapters attach to backend resources so the reconciler can
@@ -232,6 +237,13 @@ type Pauser interface {
Resume(ctx context.Context, ref string) error
}
+// PauseCanceller is implemented by adapters whose backend keeps an instance
+// serving until a pause completes and lets a Resume call it off, keeping the
+// instance as it is. Where a resume cannot, it waits for the pause to finish.
+type PauseCanceller interface {
+ CancelsPause() bool
+}
+
// NativeIdler is implemented by adapters whose backend pauses idle instances
// itself (from CreateRequest.IdleTimeoutSeconds) and wakes them on access.
// The scheduler then leaves idling to the backend instead of sweeping, and
diff --git a/pkg/api/errors.go b/pkg/api/errors.go
index 3788be2..faef5a4 100644
--- a/pkg/api/errors.go
+++ b/pkg/api/errors.go
@@ -58,7 +58,7 @@ func (e *Error) HTTPStatus() int {
return http.StatusUnprocessableEntity
case CodeUnsupportedOperation:
return http.StatusNotImplemented
- case CodeProviderUnavailable:
+ case CodeProviderUnavailable, "insufficient_capacity":
return http.StatusServiceUnavailable
}
return http.StatusInternalServerError
diff --git a/pkg/api/types.go b/pkg/api/types.go
index 726c59f..7fbf840 100644
--- a/pkg/api/types.go
+++ b/pkg/api/types.go
@@ -18,6 +18,7 @@ const (
StatusProvisioning SandboxStatus = "provisioning"
StatusRunning SandboxStatus = "running"
StatusStopped SandboxStatus = "stopped"
+ StatusPausing SandboxStatus = "pausing"
StatusPaused SandboxStatus = "paused"
StatusTerminating SandboxStatus = "terminating"
StatusTerminated SandboxStatus = "terminated"
@@ -33,7 +34,7 @@ func (s SandboxStatus) Terminal() bool {
// quota. Paused microVMs keep their memory resident, so they still reserve.
func (s SandboxStatus) ReservesCompute() bool {
switch s {
- case StatusQueued, StatusProvisioning, StatusRunning, StatusPaused, StatusTerminating:
+ case StatusQueued, StatusProvisioning, StatusRunning, StatusPausing, StatusPaused, StatusTerminating:
return true
}
return false
@@ -94,6 +95,16 @@ type CreateSandboxRequest struct {
// no exec, file, port or keepalive call; the next such call wakes it.
// 0 uses the operator default; -1 turns it off.
IdleTimeoutSeconds int `json:"idleTimeoutSeconds,omitempty"`
+ // MaxRunSeconds pauses (or stops) the sandbox after it has run this long
+ // without a break, busy or not, so long-running sandboxes give their slot
+ // back. 0 uses the operator default; -1 turns it off.
+ MaxRunSeconds int `json:"maxRunSeconds,omitempty"`
+ // MaxPausedSeconds deletes the sandbox after it has stayed paused or
+ // stopped this long. 0 uses the operator default; -1 turns it off.
+ MaxPausedSeconds int `json:"maxPausedSeconds,omitempty"`
+ // Preemptible false keeps the scheduler from pausing this sandbox to make
+ // room for other work on a full pool. Default true.
+ Preemptible *bool `json:"preemptible,omitempty"`
}
// UpdateSandboxRequest is the body of PATCH /v1/sandboxes/{id}.
@@ -101,6 +112,9 @@ type UpdateSandboxRequest struct {
Labels map[string]string `json:"labels,omitempty"`
TTLSeconds *int `json:"ttlSeconds,omitempty"`
IdleTimeoutSeconds *int `json:"idleTimeoutSeconds,omitempty"`
+ MaxRunSeconds *int `json:"maxRunSeconds,omitempty"`
+ MaxPausedSeconds *int `json:"maxPausedSeconds,omitempty"`
+ Preemptible *bool `json:"preemptible,omitempty"`
}
// Sandbox is an isolated compute environment.
@@ -131,15 +145,29 @@ type Sandbox struct {
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
// IdledAt is set while the sandbox is stopped or paused for being idle;
// the next exec, file, port or keepalive call wakes it.
- IdledAt *time.Time `json:"idledAt,omitempty"`
- FromSnapshot string `json:"fromSnapshot,omitempty"`
- Attempts int `json:"attempts"`
- CreatedBy string `json:"createdBy"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- StartedAt *time.Time `json:"startedAt,omitempty"`
- EndedAt *time.Time `json:"endedAt,omitempty"`
- Decision *PlacementDecision `json:"decision,omitempty"`
+ IdledAt *time.Time `json:"idledAt,omitempty"`
+ // MaxRunSeconds and MaxPausedSeconds are the lifecycle limits in force
+ // (0: off); see CreateSandboxRequest.
+ MaxRunSeconds int `json:"maxRunSeconds,omitempty"`
+ MaxPausedSeconds int `json:"maxPausedSeconds,omitempty"`
+ // Preemptible reports whether the scheduler may pause this sandbox to make
+ // room on a full pool (pools with a preemption policy only).
+ Preemptible bool `json:"preemptible"`
+ // PausedAt is when the sandbox last paused or stopped.
+ PausedAt *time.Time `json:"pausedAt,omitempty"`
+ // SlotReleased is true while a paused sandbox holds no pool slot: its pool
+ // checkpoints on pause (lifecycle.pause-checkpoint).
+ SlotReleased bool `json:"slotReleased,omitempty"`
+ // ResumeRequestedAt is set while a resume waits for a free slot.
+ ResumeRequestedAt *time.Time `json:"resumeRequestedAt,omitempty"`
+ FromSnapshot string `json:"fromSnapshot,omitempty"`
+ Attempts int `json:"attempts"`
+ CreatedBy string `json:"createdBy"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ StartedAt *time.Time `json:"startedAt,omitempty"`
+ EndedAt *time.Time `json:"endedAt,omitempty"`
+ Decision *PlacementDecision `json:"decision,omitempty"`
}
// PlacementDecision explains where a sandbox was placed and why.
@@ -362,9 +390,36 @@ type Pool struct {
CredentialRef string `json:"credentialRef,omitempty"`
Source string `json:"source"` // config | api
Capabilities []string `json:"capabilities,omitempty"`
- Health *PoolHealth `json:"health,omitempty"`
- Capacity *PoolCapacity `json:"capacity,omitempty"`
- Active int `json:"active"`
+ // Preemption lets the scheduler pause running sandboxes to make room for
+ // waiting work on this pool. Nil or mode "off" never preempts.
+ Preemption *Preemption `json:"preemption,omitempty"`
+ Health *PoolHealth `json:"health,omitempty"`
+ Capacity *PoolCapacity `json:"capacity,omitempty"`
+ Active int `json:"active"`
+ // Released counts paused sandboxes on the pool that hold no slot.
+ Released int `json:"released,omitempty"`
+}
+
+// Preemption modes.
+const (
+ PreemptOff = "off"
+ PreemptIdle = "idle" // only sandboxes with no activity for IdleSeconds (recommended)
+ PreemptLRU = "lru" // the least recently used running sandbox, busy or not
+)
+
+// Preemption is a pool's policy for making room when work waits for a slot.
+// Only pools that checkpoint on pause (lifecycle.pause-checkpoint) preempt:
+// elsewhere a pause would not free the slot. The victim is paused, keeping
+// its memory, and resumes in the queue like any other paused sandbox.
+type Preemption struct {
+ Mode string `json:"mode"`
+ // MinRunSeconds protects a sandbox that started or resumed recently from
+ // mode lru, so busy sandboxes do not trade the slot back and forth.
+ // Default 600.
+ MinRunSeconds int `json:"minRunSeconds,omitempty"`
+ // IdleSeconds is how long without activity makes a sandbox a candidate in
+ // mode idle. Default 60.
+ IdleSeconds int `json:"idleSeconds,omitempty"`
}
// PoolHealth is the last observed health of a pool.
diff --git a/pkg/client/client.go b/pkg/client/client.go
index 7e578c0..1f04d59 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -58,6 +58,20 @@ func (c *Client) Do(ctx context.Context, method, path string, in, out any, heade
}
func (c *Client) send(ctx context.Context, method, path string, in any, header ...http.Header) (*http.Response, error) {
+ return c.sendVia(c.HTTP, ctx, method, path, in, header...)
+}
+
+// streaming returns the HTTP client for a response that is read for as long
+// as a command runs: the same transport and settings without the overall
+// timeout, which would cut the stream however alive it is. The caller's
+// context bounds it instead.
+func (c *Client) streaming() *http.Client {
+ h := *c.HTTP
+ h.Timeout = 0
+ return &h
+}
+
+func (c *Client) sendVia(hc *http.Client, ctx context.Context, method, path string, in any, header ...http.Header) (*http.Response, error) {
var body []byte
ct := ""
switch v := in.(type) {
@@ -92,7 +106,7 @@ func (c *Client) send(ctx context.Context, method, path string, in any, header .
}
}
}
- resp, err := c.HTTP.Do(req)
+ resp, err := hc.Do(req)
if err != nil {
return nil, err
}
@@ -183,7 +197,7 @@ func (c *Client) Exec(ctx context.Context, id string, req api.ExecRequest) (api.
// ExecStream runs a command and calls fn for each output event. It returns
// the exit code.
func (c *Client) ExecStream(ctx context.Context, id string, req api.ExecRequest, fn func(api.ExecEvent)) (int, error) {
- resp, err := c.send(ctx, http.MethodPost, "/v1/sandboxes/"+url.PathEscape(id)+"/exec?stream=1", req)
+ resp, err := c.sendVia(c.streaming(), ctx, http.MethodPost, "/v1/sandboxes/"+url.PathEscape(id)+"/exec?stream=1", req)
if err != nil {
return -1, err
}
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
new file mode 100644
index 0000000..800da13
--- /dev/null
+++ b/pkg/client/client_test.go
@@ -0,0 +1,38 @@
+package client
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/different-ai/differently/pkg/api"
+)
+
+// A streamed exec lasts as long as its command. The client's overall request
+// timeout (10 minutes by default) must not cut it off; the caller's context
+// bounds it.
+func TestExecStreamOutlivesTheRequestTimeout(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ fmt.Fprint(w, "event: stdout\ndata: {\"stream\":\"stdout\",\"data\":\"started\\n\"}\n\n")
+ w.(http.Flusher).Flush()
+ time.Sleep(400 * time.Millisecond) // the command is still running
+ fmt.Fprint(w, "event: exit\ndata: {\"exitCode\":0}\n\n")
+ }))
+ defer srv.Close()
+ c := New(srv.URL, "dly_test_key")
+ c.HTTP.Timeout = 100 * time.Millisecond
+
+ var out string
+ code, err := c.ExecStream(context.Background(), "sbx_1", api.ExecRequest{Command: "sleep 1"}, func(ev api.ExecEvent) { out += ev.Data })
+ if err != nil || code != 0 || out != "started\n" {
+ t.Fatalf("stream cut short: code %d, output %q, err %v", code, out, err)
+ }
+ // Ordinary requests keep the timeout.
+ if c.HTTP.Timeout != 100*time.Millisecond {
+ t.Fatalf("the shared client's timeout changed to %v", c.HTTP.Timeout)
+ }
+}
diff --git a/sdk/python/README.md b/sdk/python/README.md
index 025bd03..a6c8490 100644
--- a/sdk/python/README.md
+++ b/sdk/python/README.md
@@ -8,11 +8,16 @@ picks the pool; the SDK never needs to know which backend runs the sandbox.
## Install
+The SDK is not on PyPI yet. Install it from this repository:
+
```sh
-pip install differently
+pip install "git+https://github.com/different-ai/differently.git#subdirectory=sdk/python"
```
-Until the package is published, install from a checkout: `pip install ./sdk/python`.
+or from a checkout: `pip install ./sdk/python`.
+
+Do not run `pip install differently`: that name on PyPI belongs to an
+unrelated project.
## Quickstart
@@ -104,12 +109,12 @@ and other POSTs are never retried.
| `Sandbox` handle | `exec`, `exec_stream`, `files.list/read/read_text/write`, `stop`, `start`, `pause`, `resume`, `snapshot`, `fork`, `expose_port`, `explain_placement`, `refresh`, `update`, `wait`, `delete` |
| `snapshots` | `list`, `get`, `delete` |
| `projects` | `list`, `create`, `get`, `delete` |
-| `api_keys` | `list`, `create`, `revoke` |
+| `api_keys` | `list`, `create`, `get`, `rotate`, `revoke` |
| `quotas`, `usage` | `get` |
| `audit_events` | `list`, `iterate` |
| `policies` | `list`, `put`, `delete` |
| `capabilities`, `profiles`, `templates`, `classifications` | `list` |
-| `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.set_quota`, `pools.{list,get,create,update,drain,undrain,delete}`, `agents.list`, `trust_report`, `verify_audit`, `terminate_sandbox`, `reconcile` |
+| `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.set_quota`, `pools.{list,get,create,update,drain,undrain,use,delete}`, `secrets.{list,put,delete}`, `trust_report`, `verify_audit`, `terminate_sandbox`, `reconcile` |
| client | `me`, `version` |
Keyword arguments are snake_case. Records come back as plain dicts keyed exactly
diff --git a/sdk/python/differently/_resources.py b/sdk/python/differently/_resources.py
index 58a9b42..8b90278 100644
--- a/sdk/python/differently/_resources.py
+++ b/sdk/python/differently/_resources.py
@@ -146,17 +146,30 @@ def list(
resource: Optional[str] = None,
action: Optional[str] = None,
after_seq: Optional[int] = None,
+ before_seq: Optional[int] = None,
+ order: Optional[str] = None,
limit: Optional[int] = None,
org_id: Optional[str] = None,
) -> Page[AuditEvent]:
- """Returns one page in ascending sequence order."""
- query = {"resource": resource, "action": action, "afterSeq": after_seq, "limit": limit, "orgId": org_id}
+ """Returns one page, oldest first by default.
+
+ ``order="desc"`` returns the newest events first; the page's
+ ``next_cursor`` is then the ``before_seq`` of the next, older page.
+ """
+ query = {
+ "resource": resource, "action": action, "afterSeq": after_seq, "beforeSeq": before_seq,
+ "order": order, "limit": limit, "orgId": org_id,
+ }
page = self._http.json("GET", "/v1/audit-events", query=query)
return Page(items=page.get("items") or [], next_cursor=page.get("nextCursor") or None)
def iterate(self, **filters: Any) -> Iterator[AuditEvent]:
- """Yields every matching event, following pagination. Takes ``list``'s filters."""
+ """Yields every matching event from oldest to newest, following pagination.
+
+ Takes ``list``'s filters; ``order`` is always ascending here.
+ """
after_seq = filters.pop("after_seq", None)
+ filters.pop("order", None)
while True:
page = self.list(after_seq=after_seq, **filters)
yield from page.items
diff --git a/sdk/python/differently/_sandbox.py b/sdk/python/differently/_sandbox.py
index b63d8cf..2c0f463 100644
--- a/sdk/python/differently/_sandbox.py
+++ b/sdk/python/differently/_sandbox.py
@@ -163,13 +163,20 @@ def update(
labels: Optional[Mapping[str, str]] = None,
ttl_seconds: Optional[int] = None,
idle_timeout_seconds: Optional[int] = None,
+ max_run_seconds: Optional[int] = None,
+ max_paused_seconds: Optional[int] = None,
+ preemptible: Optional[bool] = None,
) -> "Sandbox":
"""Merges labels (an empty value removes one), resets the TTL, and/or sets
- the idle timeout (at least 60 seconds, or -1 to turn it off)."""
+ the idle timeout (at least 60 seconds, or -1 to turn it off), the
+ lifecycle limits, or whether the sandbox may be preempted."""
body = compact(
labels=dict(labels) if labels is not None else None,
ttlSeconds=ttl_seconds,
idleTimeoutSeconds=idle_timeout_seconds,
+ maxRunSeconds=max_run_seconds,
+ maxPausedSeconds=max_paused_seconds,
+ preemptible=preemptible,
)
return self._set(self._http.json("PATCH", self._path, json=body))
@@ -249,6 +256,11 @@ def pause(self) -> "Sandbox":
return self._lifecycle("pause")
def resume(self) -> "Sandbox":
+ """Resumes a paused sandbox. If its pause released the pool slot and the
+ pool is full, the resume is queued: the sandbox stays paused with
+ ``resumeRequestedAt`` set until a slot frees; ``wait()`` for running.
+ During a pause this calls it off where the backend can, and otherwise
+ queues."""
return self._lifecycle("resume")
def keepalive(self) -> "Sandbox":
@@ -300,6 +312,9 @@ def create(
priority: Optional[int] = None,
from_snapshot: Optional[str] = None,
idle_timeout_seconds: Optional[int] = None,
+ max_run_seconds: Optional[int] = None,
+ max_paused_seconds: Optional[int] = None,
+ preemptible: Optional[bool] = None,
wait: bool = True,
timeout: float = DEFAULT_WAIT_SECONDS,
idempotency_key: Optional[str] = None,
@@ -322,6 +337,9 @@ def create(
priority=priority,
fromSnapshot=from_snapshot,
idleTimeoutSeconds=idle_timeout_seconds,
+ maxRunSeconds=max_run_seconds,
+ maxPausedSeconds=max_paused_seconds,
+ preemptible=preemptible,
)
info = self._http.json(
"POST",
@@ -372,9 +390,24 @@ def iterate(self, **filters: Any) -> Iterator[Sandbox]:
return
def update(
- self, sandbox_id: str, *, labels: Optional[Mapping[str, str]] = None, ttl_seconds: Optional[int] = None
+ self,
+ sandbox_id: str,
+ *,
+ labels: Optional[Mapping[str, str]] = None,
+ ttl_seconds: Optional[int] = None,
+ idle_timeout_seconds: Optional[int] = None,
+ max_run_seconds: Optional[int] = None,
+ max_paused_seconds: Optional[int] = None,
+ preemptible: Optional[bool] = None,
) -> Sandbox:
- body = compact(labels=dict(labels) if labels is not None else None, ttlSeconds=ttl_seconds)
+ body = compact(
+ labels=dict(labels) if labels is not None else None,
+ ttlSeconds=ttl_seconds,
+ idleTimeoutSeconds=idle_timeout_seconds,
+ maxRunSeconds=max_run_seconds,
+ maxPausedSeconds=max_paused_seconds,
+ preemptible=preemptible,
+ )
return Sandbox(self._http, self._http.json("PATCH", _sandbox_path(sandbox_id), json=body))
def delete(self, sandbox_id: str) -> Sandbox:
diff --git a/sdk/python/differently/types.py b/sdk/python/differently/types.py
index f8bd61e..cb659e9 100644
--- a/sdk/python/differently/types.py
+++ b/sdk/python/differently/types.py
@@ -12,7 +12,15 @@
T = TypeVar("T")
SandboxStatus = Literal[
- "queued", "provisioning", "running", "stopped", "paused", "terminating", "terminated", "failed"
+ "queued",
+ "provisioning",
+ "running",
+ "stopped",
+ "pausing",
+ "paused",
+ "terminating",
+ "terminated",
+ "failed",
]
ExternalMode = Literal["never", "overflow", "allowed"]
PlacementStrategy = Literal["private-first", "cost", "spread"]
@@ -92,6 +100,12 @@ class SandboxInfo(_SandboxRequired, total=False):
idleTimeoutSeconds: int
lastActivityAt: str
idledAt: str
+ maxRunSeconds: int
+ maxPausedSeconds: int
+ preemptible: bool
+ pausedAt: str
+ slotReleased: bool
+ resumeRequestedAt: str
fromSnapshot: str
startedAt: str
endedAt: str
@@ -274,6 +288,8 @@ class Pool(_PoolRequired, total=False):
capabilities: List[str]
health: Dict[str, str]
capacity: Dict[str, object]
+ preemption: Dict[str, object]
+ released: int
class _PoolCapabilitiesRequired(TypedDict):
diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py
index e967391..1604360 100644
--- a/sdk/python/tests/test_client.py
+++ b/sdk/python/tests/test_client.py
@@ -176,6 +176,16 @@ def event(seq):
second = self.mock.calls("GET /v1/audit-events")[1]
self.assertEqual((second.param("afterSeq"), second.param("action")), ("2", "sandbox.create"))
+ def test_audit_events_list_newest_first(self) -> None:
+ def event(seq):
+ return {"seq": seq, "time": "t", "actor": "k", "action": "sandbox.create", "resource": "sbx", "outcome": "ok", "prevHash": "", "hash": ""}
+
+ self.mock.on("GET /v1/audit-events", reply(200, {"items": [event(9), event(8)], "nextCursor": "8"}))
+ page = self.client.audit_events.list(order="desc", limit=2, before_seq=10)
+ self.assertEqual(([ev["seq"] for ev in page.items], page.next_cursor), ([9, 8], "8"))
+ call = self.mock.calls("GET /v1/audit-events")[0]
+ self.assertEqual((call.param("order"), call.param("beforeSeq")), ("desc", "10"))
+
def test_admin_namespace(self) -> None:
org = {"id": "org_1", "name": "Acme", "suspended": True, "quota": {}, "createdAt": "t"}
self.mock.on("POST /v1/admin/organizations", reply(201, {"organization": org, "project": {"id": "prj_1"}}))
diff --git a/sdk/python/tests/test_sandboxes.py b/sdk/python/tests/test_sandboxes.py
index c387363..fb0b402 100644
--- a/sdk/python/tests/test_sandboxes.py
+++ b/sdk/python/tests/test_sandboxes.py
@@ -249,6 +249,14 @@ def test_update_sends_only_given_fields(self) -> None:
self.assertEqual(sandbox.labels, {"team": "ml"})
self.assertEqual(self.mock.calls("PATCH /v1/sandboxes/sbx_1")[0].json(), {"labels": {"team": "ml"}})
+ def test_update_sends_lifecycle_limits_and_keeps_false(self) -> None:
+ self.mock.on("PATCH /v1/sandboxes/sbx_1", reply(200, sandbox_info(maxRunSeconds=3600, preemptible=False)))
+ self.client.sandboxes.update("sbx_1", max_run_seconds=3600, max_paused_seconds=-1, preemptible=False)
+ self.assertEqual(
+ self.mock.calls("PATCH /v1/sandboxes/sbx_1")[0].json(),
+ {"maxRunSeconds": 3600, "maxPausedSeconds": -1, "preemptible": False},
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md
index d84b106..385ae9a 100644
--- a/sdk/typescript/README.md
+++ b/sdk/typescript/README.md
@@ -8,13 +8,15 @@ picks the pool; the SDK never needs to know which backend runs the sandbox.
## Install
+The SDK is not on npm yet (`npm install @differently/sdk` finds nothing).
+Build it from a checkout of this repository and install that folder:
+
```sh
-npm install @differently/sdk
+git clone https://github.com/different-ai/differently.git
+(cd differently/sdk/typescript && npm ci && npm run build)
+npm install ./differently/sdk/typescript
```
-Until the package is published, install from a checkout:
-`npm install ./sdk/typescript` (run `npm run build` there first).
-
## Quickstart
```ts
@@ -96,12 +98,12 @@ POSTs are never retried.
| `Sandbox` handle | `exec`, `execStream`, `files.list/read/readText/write`, `stop`, `start`, `pause`, `resume`, `snapshot`, `fork`, `exposePort`, `explainPlacement`, `refresh`, `update`, `wait`, `delete` |
| `snapshots` | `list`, `get`, `delete` |
| `projects` | `list`, `create`, `get`, `delete` |
-| `apiKeys` | `list`, `create`, `revoke` |
+| `apiKeys` | `list`, `create`, `get`, `rotate`, `revoke` |
| `quotas`, `usage` | `get` |
| `auditEvents` | `list`, `iterate` |
| `policies` | `list`, `put`, `delete` |
| `capabilities`, `profiles`, `templates`, `classifications` | `list` |
-| `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.setQuota`, `pools.{list,get,create,update,drain,undrain,delete}`, `agents.list`, `trustReport`, `verifyAudit`, `terminateSandbox`, `reconcile` |
+| `admin` | `organizations.{list,create,get,update,suspend,resume}`, `projects.setQuota`, `pools.{list,get,create,update,drain,undrain,use,delete}`, `secrets.{list,put,delete}`, `trustReport`, `verifyAudit`, `terminateSandbox`, `reconcile` |
| client | `me`, `version` |
`list` returns one page (`{ items, nextCursor }`); `iterate` walks every page.
diff --git a/sdk/typescript/src/resources.ts b/sdk/typescript/src/resources.ts
index 968c6b7..02e9dbc 100644
--- a/sdk/typescript/src/resources.ts
+++ b/sdk/typescript/src/resources.ts
@@ -151,18 +151,18 @@ export class AuditEvents {
this.#http = http;
}
- /** Returns one page in ascending sequence order. */
+ /** Returns one page, oldest first unless `order: "desc"` asks for the newest. */
async list(params: ListAuditEventsParams = {}): Promise> {
const page = await this.#http.json>({ method: "GET", path: "/v1/audit-events", query: { ...params } });
const items = page.items ?? [];
return page.nextCursor ? { items, nextCursor: page.nextCursor } : { items };
}
- /** Yields every matching event, following pagination. */
+ /** Yields every matching event from oldest to newest, following pagination. */
async *iterate(params: ListAuditEventsParams = {}): AsyncGenerator {
let afterSeq = params.afterSeq;
for (;;) {
- const page = await this.list({ ...params, afterSeq });
+ const page = await this.list({ ...params, order: "asc", afterSeq });
yield* page.items;
if (!page.nextCursor) return;
afterSeq = Number(page.nextCursor);
diff --git a/sdk/typescript/src/sandbox.ts b/sdk/typescript/src/sandbox.ts
index db9211f..dbd7001 100644
--- a/sdk/typescript/src/sandbox.ts
+++ b/sdk/typescript/src/sandbox.ts
@@ -247,6 +247,12 @@ export class Sandbox {
return this.#lifecycle("keepalive");
}
+ /**
+ * Resumes a paused sandbox. If its pause released the pool slot and the pool
+ * is full, the resume is queued: the sandbox stays paused with
+ * `resumeRequestedAt` set until a slot frees; `wait()` for `running`. During
+ * a pause this calls it off where the backend can, and otherwise queues.
+ */
resume(): Promise {
return this.#lifecycle("resume");
}
diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts
index 44c43e9..38b1028 100644
--- a/sdk/typescript/src/types.ts
+++ b/sdk/typescript/src/types.ts
@@ -7,6 +7,7 @@ export type SandboxStatus =
| "provisioning"
| "running"
| "stopped"
+ | "pausing"
| "paused"
| "terminating"
| "terminated"
@@ -69,6 +70,12 @@ export interface CreateSandboxParams {
fromSnapshot?: string;
/** Stop or pause after this many seconds unused (at least 60); the next call wakes it. -1 turns it off. */
idleTimeoutSeconds?: number;
+ /** Pause (or stop) after running this long without a break, busy or not (at least 60). 0 uses the operator default, -1 turns it off. */
+ maxRunSeconds?: number;
+ /** Delete after staying paused or stopped this long (at least 60). 0 uses the operator default, -1 turns it off. */
+ maxPausedSeconds?: number;
+ /** false keeps the scheduler from pausing this sandbox to make room on a full pool. Default true. */
+ preemptible?: boolean;
}
export interface UpdateSandboxParams {
@@ -77,6 +84,9 @@ export interface UpdateSandboxParams {
ttlSeconds?: number;
/** At least 60, or -1 to turn idle auto-stop off. */
idleTimeoutSeconds?: number;
+ maxRunSeconds?: number;
+ maxPausedSeconds?: number;
+ preemptible?: boolean;
}
export interface ListSandboxesParams {
@@ -117,6 +127,17 @@ export interface SandboxInfo {
lastActivityAt?: string;
/** Set while stopped or paused for being idle; the next exec, file, port or keepalive call wakes it. */
idledAt?: string;
+ /** The lifecycle limits in force (absent: off). */
+ maxRunSeconds?: number;
+ maxPausedSeconds?: number;
+ /** Whether the scheduler may pause this sandbox to make room on a full pool. */
+ preemptible?: boolean;
+ /** When the sandbox last paused or stopped. */
+ pausedAt?: string;
+ /** True while a paused sandbox holds no pool slot: its pool checkpoints on pause. */
+ slotReleased?: boolean;
+ /** Set while a resume waits for a free slot. */
+ resumeRequestedAt?: string;
fromSnapshot?: string;
attempts: number;
createdBy: string;
@@ -345,7 +366,18 @@ export interface Pool {
capabilities?: string[];
health?: PoolHealth;
capacity?: PoolCapacity;
+ /** Lets the scheduler pause running sandboxes to make room for waiting work. Absent or `off`: never. */
+ preemption?: Preemption;
active: number;
+ /** Paused sandboxes on the pool that hold no slot. */
+ released?: number;
+}
+
+export interface Preemption {
+ /** `idle`: sandboxes unused for `idleSeconds`; `lru`: the least recently used running sandbox. */
+ mode: "off" | "idle" | "lru" | (string & {});
+ minRunSeconds?: number;
+ idleSeconds?: number;
}
/** Body of POST /v1/admin/pools. Credentials are references, never values. */
@@ -466,6 +498,13 @@ export interface ListAuditEventsParams {
action?: string;
/** Return events with a sequence number greater than this. */
afterSeq?: number;
+ /** Return events with a sequence number lower than this. */
+ beforeSeq?: number;
+ /**
+ * "desc" returns the newest events first; the page's nextCursor is then the
+ * beforeSeq of the next, older page. Default "asc".
+ */
+ order?: "asc" | "desc";
/** Page size, 1-1000 (server default 100). */
limit?: number;
/** Operator keys only. */
diff --git a/sdk/typescript/test/client.test.ts b/sdk/typescript/test/client.test.ts
index bb7f5fc..4b541c1 100644
--- a/sdk/typescript/test/client.test.ts
+++ b/sdk/typescript/test/client.test.ts
@@ -181,6 +181,17 @@ test("audit events iterate with afterSeq", async () => {
assert.equal(second?.query.get("action"), "sandbox.create");
});
+test("audit events list the newest first with order desc", async () => {
+ const event = (seq: number) => ({ seq, time: "t", actor: "k", action: "sandbox.create", resource: "sbx", outcome: "ok", prevHash: "", hash: "" });
+ mock.on("GET /v1/audit-events", reply(200, { items: [event(9), event(8)], nextCursor: "8" }));
+ const page = await client.auditEvents.list({ order: "desc", limit: 2, beforeSeq: 10 });
+ assert.deepEqual(page.items.map((e) => e.seq), [9, 8]);
+ assert.equal(page.nextCursor, "8");
+ const [call] = mock.calls("GET /v1/audit-events");
+ assert.equal(call?.query.get("order"), "desc");
+ assert.equal(call?.query.get("beforeSeq"), "10");
+});
+
test("admin namespace", async () => {
const org = { id: "org_1", name: "Acme", suspended: true, quota: {}, createdAt: "t" };
mock.on("POST /v1/admin/organizations", reply(201, { organization: { ...org, suspended: false }, project: { id: "prj_1" } }));
diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index 94961a6..d1b044f 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -41,6 +41,8 @@ export default defineConfig({
{ label: 'OpenSandbox pools', link: '/docs/guides/opensandbox-pools/' },
{ label: 'Organization onboarding', link: '/docs/guides/organization-onboarding/' },
{ label: 'Hybrid overflow', link: '/docs/guides/hybrid-overflow/' },
+ { label: 'Keys and providers', link: '/docs/guides/keys-and-providers/' },
+ { label: 'OpenWork on Differently', link: '/docs/guides/openwork/' },
] },
{ label: 'Reference', items: [
{ label: 'HTTP API', link: '/docs/reference/api/' },
@@ -55,6 +57,7 @@ export default defineConfig({
{ label: 'Concepts', items: [
{ label: 'Architecture', link: '/docs/architecture/' },
{ label: 'Placement policy', link: '/docs/scheduling/placement-policy/' },
+ { label: 'Capacity', link: '/docs/scheduling/capacity/' },
{ label: 'Storage and lifecycle', link: '/docs/storage-and-lifecycle/' },
{ label: 'Reliability and performance', link: '/docs/reliability-and-performance/' },
] },
@@ -69,6 +72,7 @@ export default defineConfig({
{ label: 'Proofs of concept', link: '/docs/proof-of-concepts/' },
{ label: 'OpenSandbox spike', link: '/docs/research/opensandbox-spike/' },
{ label: 'Acceptance scenarios', link: '/docs/acceptance-scenarios/' },
+ { label: 'Maintaining', link: '/docs/maintaining/' },
] },
],
}),
diff --git a/site/pages-src/features.md b/site/pages-src/features.md
index dfd3c14..6257601 100644
--- a/site/pages-src/features.md
+++ b/site/pages-src/features.md
@@ -3,10 +3,10 @@ title: Features
description: What OpenSandbox provides, what Differently adds on top, and how that compares to using a single provider.
---
-Differently is an orchestration and resource-management layer. It does not run sandboxes itself: it decides where each one runs and keeps account of it. Its default private runtime is [OpenSandbox](https://github.com/opensandbox-group/OpenSandbox), run by the operator; other pools can be any of the [built-in provider adapters](/differently/docs/providers/), from E2B and Daytona to the cloud providers' sandbox services, or any service that speaks the [adapter protocol](/differently/docs/adapters/specification/).
+Differently is an orchestration and resource-management layer. It does not run sandboxes itself: it decides where each one runs and keeps account of it. Its default private runtime is [OpenSandbox](https://github.com/opensandbox-group/OpenSandbox), run by the operator; other pools can be Daytona or Freestyle, one of the [experimental provider adapters](/differently/docs/providers/) compiled in with a build tag (E2B, the cloud providers' sandbox services and more), or any service that speaks the [adapter protocol](/differently/docs/adapters/specification/).
:::caution[Pre-release]
-The end-to-end suite passes 14/14 against real OpenSandbox servers on the Docker runtime and on the Kubernetes runtime (single-node k3s). Fast Sandbox (microVM) ran live on one node, without the end-to-end suite yet; Daytona and Freestyle ran against live accounts; multi-node and managed clusters, and live accounts for the other managed providers, are not verified yet. See the [roadmap](/differently/docs/roadmap/).
+The end-to-end suite passes 14/14 against real OpenSandbox servers on the Docker runtime and on the Kubernetes runtime (single-node k3s), and on a Fast Sandbox microVM pool on a two-node k3s cluster (14 of 14 run). Daytona and Freestyle ran against live accounts. Node drains, more than one KVM node, managed clusters and live accounts for the other managed providers are not verified yet. See the [roadmap](/differently/docs/roadmap/).
:::
## What OpenSandbox provides
@@ -16,7 +16,7 @@ These come from the runtime. Differently exposes them through its own API when t
| Feature | Notes |
|---|---|
| Runtimes | Docker, Kubernetes, and Fast Sandbox (Firecracker microVMs managed by a per-node agent) |
-| Lifecycle | Create and delete everywhere. Pause and resume keep memory on Docker and Fast Sandbox (a Fast Sandbox pause uploads a full checkpoint and takes minutes); stop and start keep the disk on Kubernetes, through a snapshot registry |
+| Lifecycle | Create and delete everywhere. Pause and resume keep memory on Docker and Fast Sandbox (a Fast Sandbox pause uploads a full checkpoint and takes minutes, and a paused microVM leaves the host); stop and start keep the disk on Kubernetes, through a snapshot registry |
| Exec | Commands with env, cwd, timeouts, exit codes and streamed output; stdin is staged in a file the command reads |
| Files | Upload, download and list |
| Snapshots and fork | Disk snapshots and fork on Docker, on Fast Sandbox, and on Kubernetes through a snapshot registry; memory snapshots are not exposed yet |
@@ -39,10 +39,13 @@ OpenSandbox is a single-trust-domain runtime: one server-wide API key, no organi
| Fail-closed external capacity | Registering an external provider authorizes nothing until policy explicitly allows it |
| One API across backends | OpenSandbox pools, 15 more [provider adapters](/differently/docs/providers/) and custom plugins behind one HTTP contract, CLI and TypeScript, Python and Go SDKs |
| Idle auto-stop | Sandboxes left idle are stopped or paused and wake on the next call |
+| Capacity that follows use | A paused sandbox on a pool that checkpoints (Fast Sandbox, Freestyle) gives its slot back; a resume on a full pool waits in a queue; preemption and run or pause limits keep a pool from filling with forgotten sandboxes. See [capacity](/differently/docs/scheduling/capacity/) |
+| Provider keys and switching | Provider keys are stored sealed in Differently or referenced; one command moves new sandboxes to another provider, and API keys rotate with a grace period |
| Placement explanations | `GET /v1/sandboxes/{id}/placement` lists the pools considered, the ones rejected and why |
| Tamper-evident audit | Hash-chained audit events with a verify endpoint and JSON-lines export |
| Trust report | Endpoints, credentials by reference, external placements, admin actions, audit chain status and what Differently cannot prove |
| Reconciliation | The control plane reconciles its state against every backend |
+| Admin interfaces | A built-in dashboard, and a separate [admin console](/differently/docs/guides/console/) you can host as static files anywhere or run on Node with sign-in |
| Self-hosted end to end | No hosted service, license server or telemetry; Postgres is the only production dependency |
## Compared to a single provider
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 36acb2e..8f93e7c 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -5,7 +5,7 @@ import { Code } from 'astro:components';
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
const repo = 'https://github.com/different-ai/differently';
const releases = `${repo}/releases`;
-const version = 'v0.1.0-alpha.2';
+const version = 'v0.1.0-alpha.3';
// Launch video slot. Set videoSrc to a video URL (and optionally a poster image and a
// WebVTT captions file) to show it under the hero. While videoSrc is '' nothing renders.
@@ -106,8 +106,9 @@ const ways = [
title: 'Existing Kubernetes',
where: 'EKS · GKE · AKS · k3s · RKE2',
what: 'Install the Helm chart on an existing cluster, next to an OpenSandbox server.',
- status: 'Documented',
- kind: 'documented',
+ detail: 'Tested with Helm on single-node and two-node k3s on Azure. Managed EKS, GKE and AKS are documented and have not been run yet.',
+ status: 'Verified on k3s',
+ kind: 'verified',
href: `${base}/docs/deploy/kubernetes/`,
cta: 'Kubernetes guide',
},
@@ -123,22 +124,21 @@ const ways = [
// Project status. verified: ran against real infrastructure. notVerified: not run yet.
const verified = [
- ['End-to-end suite', "14 of 14 tests pass on OpenSandbox's Docker runtime (arm64) and its Kubernetes runtime (single-node k3s on Azure, installed with Helm)."],
+ ['End-to-end suite', "14 of 14 tests pass on OpenSandbox's Docker runtime (arm64), its Kubernetes runtime (single-node k3s on Azure, installed with Helm) and a Fast Sandbox microVM pool (two-node k3s)."],
['Kubernetes runtime', 'Stop/start with the disk kept, snapshots and fork through a snapshot registry, signed port URLs including WebSockets, and egress policy.'],
['Idle auto-stop', 'Idle sandboxes stop (Kubernetes) or pause (Docker) and wake on the next call.'],
['Release artifacts', 'The published chart and images install from public registries; the first sandbox runs in about 2 s.'],
- ['Recorded runtimes', 'Real OpenSandbox responses from both runtimes are replayed in every go test.'],
- ['OpenWork', 'Workbot computers run through Differently as a sandbox gateway, behind a flag: commands with Python tools, files, and stop/start.'],
+ ['Recorded runtimes', 'Real OpenSandbox responses from the Docker, Kubernetes and Fast Sandbox runtimes are replayed in every go test.'],
+ ['OpenWork', "Workbot computers run through Differently as a sandbox gateway, behind a flag: commands with Python tools, files, pause and resume, and Workbot's desktop on Fast Sandbox microVMs."],
['Failure isolation', 'An image that cannot be pulled fails that sandbox only; the shared pool stays healthy.'],
- ['agent-sandbox', 'The adapter runs against a Kubernetes cluster (kind, runc).'],
+ ['agent-sandbox', 'The experimental adapter runs against a Kubernetes cluster (kind, runc).'],
['Daytona and Freestyle', 'The adapters run against live accounts: exec, files, port URLs, stop/start and snapshots on both, and egress policy, pause/resume, memory snapshots and fork on Freestyle.'],
- ['Fast Sandbox', "Firecracker microVMs on one node: Workbot's desktop streamed over a port URL, paused and resumed with its memory (a pause takes minutes), idled and woken by a request."],
+ ['Fast Sandbox', "Firecracker microVMs on Azure: the end-to-end suite passes on the microVM pool, and Workbot's desktop streams over a port URL, pauses and resumes with its memory (a pause takes minutes), idles and wakes on a request."],
];
const notVerified = [
- 'The end-to-end suite on Fast Sandbox microVMs',
- 'Multi-node clusters and node drains',
+ 'Node drains, and more than one KVM node',
'Managed EKS, GKE and AKS clusters',
- 'The other 12 provider adapters against live accounts; each is tested against a fake of its published API',
+ 'The other 12 provider adapters against live accounts; each is tested against a fake of its published API, is experimental and is left out of the published build',
];
const inProgress: string[] = [];
@@ -152,7 +152,7 @@ const features = [
{
k: '01',
title: 'Provider adapters',
- body: '16 built-in adapters, from self-hosted OpenSandbox to E2B, Vercel and the AWS, Azure and Google Cloud sandbox services, plus a plugin protocol for any other backend. Operators change where work goes; applications keep the same API.',
+ body: 'OpenSandbox, Daytona and Freestyle adapters in every build, a plugin protocol for any other backend, and 13 experimental adapters (E2B, Vercel, the AWS, Azure and Google Cloud sandbox services and more) you can compile in. Operators change where work goes; applications keep the same API.',
href: '/docs/providers/',
cta: 'Provider catalog',
},
@@ -177,11 +177,25 @@ const features = [
href: '/docs/security/trust-model/',
cta: 'Security and trust model',
},
+ {
+ k: '05',
+ title: 'Capacity that follows use',
+ body: 'A paused sandbox on a pool that checkpoints, such as Fast Sandbox microVMs or Freestyle, gives its slot back, and its resume waits in a queue when the pool is full. Preemption and run or pause limits keep pools from filling with forgotten sandboxes.',
+ href: '/docs/scheduling/capacity/',
+ cta: 'Capacity',
+ },
+ {
+ k: '06',
+ title: 'Admin console',
+ body: 'Sandboxes, keys, providers, policies, usage, audit and the trust report in one small app. Host it as static files anywhere, even only on your laptop, or run it on Node with sign-in next to a Differently you keep private.',
+ href: '/docs/guides/console/',
+ cta: 'Admin console guide',
+ },
];
const fromRuntime = [
['Sandbox runtimes', 'Docker, Kubernetes, and Fast Sandbox (Firecracker microVMs on a per-node agent).'],
- ['Lifecycle', 'Create and delete; pause and resume on Docker; stop and start on Kubernetes; a provider-side timeout as a TTL backstop.'],
+ ['Lifecycle', 'Create and delete; pause and resume on Docker and Fast Sandbox; stop and start on Kubernetes; a provider-side timeout as a TTL backstop.'],
['Exec and files', 'Commands with env, cwd, timeouts and streamed output; upload, download and list files.'],
['Snapshots', 'Disk snapshots and fork on Docker, Kubernetes and Fast Sandbox; memory-preserving pause on Docker and Fast Sandbox (a Fast Sandbox pause takes minutes).'],
['Open source', 'Apache-2.0, self-hostable, with SDKs in several languages.'],
@@ -190,9 +204,10 @@ const added = [
['Multi-tenancy', 'Organizations, projects and scoped API keys. OpenSandbox has one server-wide key.'],
['Quotas and fair queueing', 'Usage derived from state, enforced at admission, queued fairly across tenants.'],
['Policy-gated placement', 'Global, org and project policy plus data classification decide which pool may run each sandbox. External pools fail closed.'],
- ['Many backends, one API', '16 built-in provider adapters and plugins behind one contract and one SDK.'],
+ ['Many backends, one API', 'OpenSandbox, Daytona, Freestyle and plugins behind one contract and one SDK, with 13 more experimental adapters.'],
['Placement explanations', 'Every decision records the pools considered, the ones rejected and why.'],
['Audit and trust report', 'A tamper-evident audit chain and one report of endpoints, credentials by reference, external placements and limits.'],
+ ['Capacity that follows use', 'A paused microVM gives its pool slot back; resumes queue on a full pool, and preemption makes room for waiting work.'],
];
const compare = [
['Where sandboxes can run', 'One runtime or one vendor', 'Every registered pool, chosen per request'],
@@ -210,7 +225,7 @@ const compare = [
Differently · Open-source sandbox orchestration
-
+
@@ -251,7 +266,7 @@ const compare = [
Everyone builds sandboxes.We orchestrate them.
Differently is an open-source control plane for sandboxes. It runs on any cloud or on bare metal
- and places each sandbox on OpenSandbox, E2B, Daytona, a cloud provider's sandbox service or a plugin,
+ and places each sandbox on OpenSandbox, Daytona, Freestyle or a plugin,
according to placement policy. Applications use one API; quotas, placement decisions and audit live in the control plane.
@@ -300,7 +315,7 @@ const compare = [
Postgres
OpenSandbox Docker · Kubernetes runtime
OpenSandbox Fast Sandbox microVMs
-
Providers E2B · Daytona · cloud
+
Providers Daytona · Freestyle
one API
diff --git a/site/src/styles/landing.css b/site/src/styles/landing.css
index 7b4b956..3f76e8e 100644
--- a/site/src/styles/landing.css
+++ b/site/src/styles/landing.css
@@ -96,7 +96,7 @@ section:not(.hero) { padding: 96px 0; border-top: 1px solid var(--line); }
}
.note { color: var(--muted); font-size: 0.875rem; margin-top: 16px; }
-.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
+.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
.grid article { border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 28px; display: flex; flex-direction: column; gap: 12px; }
.grid .k { font: 0.75rem var(--mono); color: var(--muted); }
.grid p { color: var(--muted); margin: 0; flex: 1; font-size: 0.9375rem; }
diff --git a/test/e2e/README.md b/test/e2e/README.md
index 80f92ba..de64b54 100644
--- a/test/e2e/README.md
+++ b/test/e2e/README.md
@@ -69,7 +69,9 @@ the pool `TestStoredProviderKeyAndSwitching` registers, and
`DIFFERENTLY_E2E_POOL` names the config's private pool (`opensandbox`). The
server's config must match [`config.yaml`](config.yaml) where the tests
depend on it: the `small` and `medium` profiles, `audit.failClosed: true`
-and a short `scheduler.reconcileIntervalSeconds`. On a Fast Sandbox pool, sandboxes boot templates, not images:
+and a short `scheduler.reconcileIntervalSeconds`. With several server replicas, point
+`DIFFERENTLY_E2E_METRICS_URL` at the scheduler leader's pod: only the
+leader counts sandbox transitions. On a Fast Sandbox pool, sandboxes boot templates, not images:
`DIFFERENTLY_E2E_TEMPLATE` and `DIFFERENTLY_E2E_PYTHON_TEMPLATE` name them,
and the pool's `templateId` must be that template. Tests adapt to what the
pool advertises: where pause or snapshots are not offered (the Kubernetes
@@ -112,9 +114,12 @@ them precisely instead.
| 2026-10-29 | Azure k3s and Colima, as above | same | 14/14 on both; an idle sandbox is stopped (Kubernetes) or paused (Docker) after 60 s and woken by the next exec in about 1.7 s (Kubernetes) or under 0.1 s (Docker) |
| 2026-10-28 | k3s v1.36.5 on an Azure `Standard_D8s_v6`, x86_64; Differently from its Helm chart with embedded PostgreSQL | server, controller and `execd` release-1.2.0, Kubernetes runtime, no snapshot registry | 12/12 passed (pause and snapshots checked as refusals); sandboxes running in about 1.4–2.7 s; an unpullable image retries after OpenSandbox's 60 s pod-ready timeout with the pool healthy |
| 2026-10-29 | same Azure VM; pool `opensandbox-microvm` (`runtime: fastsandbox`), template `tpl-1cbcfa94` (1 vCPU, 1 GiB, guest kernel 6.18.36+) | server, Fast Sandbox and `execd` release-1.2.0 | **Partial run**, stopped when the VM was deallocated. Sandboxes running in about 1.3 s; files and pause/resume passed (pause about 52 s for 1 GiB); sandbox env and a template other than the pool's are refused precisely (the tests now expect that). Fork failed with a bare 422 from OpenSandbox on restore, not yet diagnosed. Port URL, idle wake, memory kept and the desktop stream did not run |
+| 2026-10-29 | Azure, two-node k3s (`Standard_D8s_v6` + `Standard_D16s_v6` KVM node), Postgres Flexible Server, two server replicas | server 1.2.0, Fast Sandbox with a 6.18.36 guest kernel with nftables; pool `runtime: fastsandbox` with OpenWork's Workbot desktop template | 14/14 run passed (desktop, unpullable-image, idle-sweep and switching tests not run); pause about 3–4 min for the 4 GiB desktop, idle wake by a port request 2.3 s |
Not covered yet:
-- the full suite on OpenSandbox's Fast Sandbox (Firecracker microVM) runtime (one partial run, above);
-- several nodes and a node drain on the Kubernetes runtime;
-- external providers.
+- on Fast Sandbox, the four tests not run there (the e2e desktop image,
+ unpullable images, Differently's own idle sweep and provider switching);
+- a node drain, and more than one KVM node;
+- external providers through this suite (Daytona and Freestyle have run
+ with their adapters' live tests instead).
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
index 9c2c60d..327adc5 100644
--- a/test/e2e/e2e_test.go
+++ b/test/e2e/e2e_test.go
@@ -60,7 +60,9 @@ func env(k, def string) string {
}
func ctx(t *testing.T) context.Context {
- c, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
+ // A Fast Sandbox pause or snapshot writes a full checkpoint, minutes for
+ // a desktop-sized template, and a test may do both.
+ c, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
t.Cleanup(cancel)
return c
}
@@ -270,7 +272,9 @@ func TestExecSemantics(t *testing.T) {
if advertises(t, "isolation.microvm") {
// A Fast Sandbox template fixes the workload: sandbox env is refused
// with a reason that says so, and per-command env still works.
- refused, err := c.CreateSandbox(ctx, api.CreateSandboxRequest{Template: defaultTemplate, Env: map[string]string{"GREETING": "hello"}}, false)
+ // Pinned to the pool under test: on an install with other private
+ // pools the refused sandbox would be retried there.
+ refused, err := c.CreateSandbox(ctx, api.CreateSandboxRequest{Template: defaultTemplate, Env: map[string]string{"GREETING": "hello"}, Placement: &api.Placement{AllowedPools: []string{mainPool}}}, false)
if err != nil {
t.Fatal(err)
}
@@ -637,8 +641,25 @@ func TestAuditTrustAndMetrics(t *testing.T) {
if strings.Contains(string(raw), osbKey) {
t.Fatal("trust report leaks the OpenSandbox API key")
}
- if len(report.Pools) != 1 || report.Pools[0].Adapter != "opensandbox" || !strings.Contains(report.Pools[0].Isolation, "container") || report.Pools[0].CredentialRef != "env:DIFFERENTLY_OPENSANDBOX_API_KEY" {
- t.Fatalf("trust report pools %+v", report.Pools)
+ // The pool under test, among any others the install has: its isolation
+ // as deployed, and its credential only as a reference.
+ isolation := "container"
+ if advertises(t, "isolation.microvm") {
+ isolation = "microVM"
+ }
+ found := false
+ for _, p := range report.Pools {
+ if p.Name != mainPool {
+ continue
+ }
+ found = true
+ ref := strings.SplitN(p.CredentialRef, ":", 2)
+ if p.Adapter != "opensandbox" || !strings.Contains(p.Isolation, isolation) || len(ref) != 2 || !slices.Contains([]string{"env", "file", "secret"}, ref[0]) {
+ t.Fatalf("trust report pool %+v", p)
+ }
+ }
+ if !found {
+ t.Fatalf("trust report has no pool %s: %+v", mainPool, report.Pools)
}
if !strings.Contains(strings.Join(report.Limitations, " "), "Differently does not isolate sandboxes itself") {
t.Fatal("trust report does not state that isolation is the runtime's")
@@ -798,7 +819,9 @@ func TestIdlePauseAndWakeThroughPortURL(t *testing.T) {
before = body
return code == http.StatusOK
})
- eventually(t, 3*time.Minute, "the idle microVM to be paused", func() bool {
+ // A minute idle, then a full checkpoint of disk and memory (about three
+ // minutes for a desktop-sized template).
+ eventually(t, 8*time.Minute, "the idle microVM to be paused", func() bool {
got, err := c.GetSandbox(context.Background(), sb.ID)
return err == nil && got.IdledAt != nil && got.Status == api.StatusPaused
})
diff --git a/test/pause/README.md b/test/pause/README.md
new file mode 100644
index 0000000..0d6aaa0
--- /dev/null
+++ b/test/pause/README.md
@@ -0,0 +1,72 @@
+# Pause runs: where a Fast Sandbox pause spends its time
+
+Scripts that pause and resume a sandbox through Differently over and over,
+split every pause into its phases from the Fast Sandbox components' logs,
+trace the state-root disk and check that the resumed guest kept its state.
+They produced the numbers in
+[docs/research/fast-sandbox-pause-time.md](../../docs/research/fast-sandbox-pause-time.md).
+
+Run them on a **test** install only: they fill pool slots, write a full
+checkpoint per pause and can drain a burstable disk's credits.
+
+## Requirements
+
+- A single-node (or KVM-node) k3s install with Fast Sandbox, as in
+ [docs/deploy/azure-vms.md](../../docs/deploy/azure-vms.md); the scripts run
+ on the KVM node with `sudo` (for `k3s kubectl logs` and `/proc`).
+- A Differently pool on that runtime, and a project key that can use it.
+- Python 3 (standard library only).
+
+```bash
+export DIFFERENTLY_URL=http://
:8080
+export DIFFERENTLY_API_KEY=... # a project key; never write it to a file here
+export DIFFERENTLY_POOL=opensandbox-microvm
+export STATE_ROOT_DEVICE=nvme0n1 # the disk under /var/lib/fast-sandbox
+python3 runset.py before idle:3 in-use:5 # fresh sandbox per scenario, N cycles each
+python3 summarize.py runs/before-*.jsonl # Markdown tables: median and p95
+```
+
+`idle` pauses a desktop as created. `in-use` first starts Xvfb, LibreOffice,
+Chromium and a process holding 384 MiB of random data, writes 300 MiB of
+random data and a copy of `/usr/share/doc` to disk, reads most of `/usr`
+into the page cache, then adds 64 MiB of new files before every later pause
+(`guest-use.sh`).
+
+## What a cycle records
+
+| Field | Source |
+|---|---|
+| `pause_s`, `resume_s` | Differently's API: from the call until the sandbox reads `paused` / `running` |
+| `resume_to_exec_s` | the resume plus the first `exec` that answers |
+| `node.phases.quiesce_and_disk_clone` | fastlet: `dump starting` to `paused for dump` (root filesystem reflink, then the VM's vCPUs stop) |
+| `node.phases.memory_dump` | fastlet: Firecracker's `dumpAPI` (memory and device state written) |
+| `node.phases.checksum_and_manifest` | fastlet: `resumed after dump` to `manifest assembled` (SHA-256 of every staged file, SHA256SUMS, manifest) |
+| `node.phases.upload` | runtime agent: `publish started` to `publish completed`; per file from Garage's request log |
+| `node.phases.metadata_commit` | fastlet `snapshot published` to the controller's `runtime released` |
+| `node.disk.` | `sampler.py`: MB read and written on the state-root disk, busy share, CPU seconds of Garage, the agent, the fastlet and Firecracker |
+| `garage_stored_mb` | growth of Garage's block directory |
+| `state_kept`, `procs_kept`, `holder_ok` | `guest-state.sh` before and after: long-lived processes with their start times, their open files, a checksum of the memory-held buffer, a tmpfs file and a disk file, the boot ID |
+
+Compression happens inside Garage (zstd on blocks it does not already
+hold), so it shows as Garage CPU during the upload, not as its own phase.
+`s3bench.py` uploads a file the way the agent does (one PUT,
+`UNSIGNED-PAYLOAD`) or as a parallel multipart upload, to measure the store
+alone.
+
+## Traps
+
+- **The page cache decides the upload.** The checksum pass reads the whole
+ checkpoint; whether the upload reads it again from disk depends on free
+ memory. Keep other memory users (tmpfs files included) off the node while
+ measuring, and compare runs on the same host.
+- **Burstable disks.** Premium SSD P6 to P20 burst to 170 MB/s on credits;
+ a long run can drain them. `dd ... iflag=direct` before and after shows
+ whether the disk still bursts.
+- **Do not delete a checkpoint a live sandbox came from.** A resumed
+ sandbox needs its checkpoint's manifest to take the next one; Fast Sandbox
+ 1.2.0 then retries the failed pause forever, dumping memory each time.
+ `drop-checkpoints.sh` (and `PAUSE_DROP_CHECKPOINTS=1`) is for test hosts,
+ with no sandbox alive.
+- `punch.py` turns a file's all-zero 64 KiB runs into holes, in place: a
+ stand-in for the sparse-pull patch on cached templates. Run it only with no
+ sandbox alive.
diff --git a/test/pause/cycle.py b/test/pause/cycle.py
new file mode 100644
index 0000000..461b6d7
--- /dev/null
+++ b/test/pause/cycle.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""Pause and resume one sandbox N times through Differently, timing each
+phase from the Fast Sandbox logs and checking the guest kept its state.
+Run on the KVM node (it reads k3s logs and the disk with sudo).
+usage: cycle.py SANDBOX_ID idle|in-use N TAG -> $PAUSE_RUNS/TAG.jsonl"""
+import datetime as dt, json, os, subprocess, sys, time
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+import dly
+
+sid, scenario, n, tag = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
+RUNS = os.environ.get("PAUSE_RUNS", os.path.join(HERE, "runs"))
+os.makedirs(RUNS, exist_ok=True)
+out = open(os.path.join(RUNS, f"{tag}.jsonl"), "a", buffering=1)
+samples = os.path.join(RUNS, f"{tag}.samples.jsonl")
+sampler = subprocess.Popen(["sudo", "python3", os.path.join(HERE, "sampler.py"), samples, os.environ.get("STATE_ROOT_DEVICE", "nvme0n1")])
+GUEST_USE = open(os.path.join(HERE, "guest-use.sh")).read()
+GUEST_STATE = open(os.path.join(HERE, "guest-state.sh")).read()
+
+def guest(script, *args):
+ r = dly.exe(sid, "cat > /tmp/g.sh <<'GEOF'\n" + script + "\nGEOF\nsh /tmp/g.sh " + " ".join(args), timeout=900)
+ return r.get("stdout", "")
+
+def state():
+ lines = guest(GUEST_STATE).splitlines()
+ state = {l.split()[0]: " ".join(l.split()[1:]) for l in lines if not l.startswith(("proc ", "fd "))}
+ state["_fds"] = [l[3:] for l in lines if l.startswith("fd ")]
+ return state, [l for l in lines if l.startswith("proc ")]
+
+def fsb_name():
+ out = subprocess.run(["sudo", "k3s", "kubectl", "-n", os.environ.get("FSB_DATAPLANE_NAMESPACE", "opensandbox-dataplane"), "get", "sandboxes", "-o", "json"], capture_output=True, text=True).stdout
+ for item in json.loads(out)["items"]:
+ if sid in json.dumps(item.get("metadata", {})) or sid in json.dumps(item.get("spec", {}).get("metadata", {})):
+ return item["metadata"]["name"]
+ return None
+
+def garage_bytes():
+ return int(subprocess.run(["sudo", "du", "-sb", "/var/lib/fast-sandbox/garage/blocks"], capture_output=True, text=True).stdout.split()[0])
+
+try:
+ if scenario == "in-use":
+ print(guest(GUEST_USE, "setup"), file=sys.stderr)
+ for i in range(1, n + 1):
+ rec = {"tag": tag, "scenario": scenario, "cycle": i, "sandbox": sid}
+ if scenario == "in-use" and i > 1:
+ rec["use"] = guest(GUEST_USE, "step", str(i)).strip()
+ before, procs_before = state()
+ name = fsb_name(); rec["fsb"] = name
+ g0 = garage_bytes()
+ since = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ time.sleep(1)
+ t0 = time.time()
+ code, body = dly.call("POST", f"/v1/sandboxes/{sid}/pause", {})
+ rec["pause_post"] = {"code": code, "s": round(time.time() - t0, 2)}
+ dly.wait(sid, "paused", timeout=1800)
+ rec["pause_s"] = round(time.time() - t0, 2)
+ time.sleep(3)
+ rec["garage_stored_mb"] = round((garage_bytes() - g0) / 1e6)
+ ph = subprocess.run(["sudo", "python3", os.path.join(HERE, "phases.py"), since, name, samples], capture_output=True, text=True)
+ try:
+ rec["node"] = json.loads(ph.stdout)
+ except ValueError:
+ rec["node_error"] = (ph.stdout + ph.stderr)[-500:]
+ time.sleep(5)
+ t1 = time.time()
+ code, body = dly.call("POST", f"/v1/sandboxes/{sid}/resume", {})
+ rec["resume_post"] = {"code": code, "s": round(time.time() - t1, 2)}
+ dly.wait(sid, "running", timeout=600)
+ rec["resume_s"] = round(time.time() - t1, 2)
+ t2 = time.time(); dly.exe(sid, "true"); rec["first_exec_s"] = round(time.time() - t2, 2)
+ rec["resume_to_exec_s"] = round(time.time() - t1, 2)
+ after, procs_after = state()
+ fds_before, fds_after = set(before.pop("_fds")), set(after.pop("_fds"))
+ if fds_before != fds_after:
+ # Which process opened or closed what: apps change their files on their own.
+ rec["fds_changed"] = {"closed": sorted(fds_before - fds_after)[:20], "opened": sorted(fds_after - fds_before)[:20]}
+ rec["state_kept"] = {k: before.get(k) == after.get(k) for k in before}
+ rec["procs_kept"] = procs_before == procs_after
+ if not all(rec["state_kept"].values()) or not rec["procs_kept"]:
+ rec["state_diff"] = {"before": before, "after": after, "procs_before": procs_before, "procs_after": procs_after}
+ rec["holder_ok"] = (after.get("holder_mem") or "x y").split()[0] == (after.get("holder_mem") or "x y").split()[1] if after.get("holder_mem") else None
+ out.write(json.dumps(rec) + "\n")
+ p = (rec.get("node") or {}).get("phases", {})
+ print(f"[{tag}] cycle {i}: pause {rec['pause_s']} s (dump {p.get('memory_dump')}, checksum {p.get('checksum_and_manifest')}, upload {p.get('upload')}, commit {p.get('metadata_commit')}), resume {rec['resume_s']} s, exec after {rec['resume_to_exec_s']} s, kept {rec['procs_kept'] and all(rec['state_kept'].values())}", flush=True)
+ time.sleep(20)
+finally:
+ sampler.terminate()
diff --git a/test/pause/dly.py b/test/pause/dly.py
new file mode 100644
index 0000000..6b98b38
--- /dev/null
+++ b/test/pause/dly.py
@@ -0,0 +1,47 @@
+"""A small Differently API client for the pause runs (standard library only).
+env: DIFFERENTLY_URL (default http://localhost:8080), DIFFERENTLY_API_KEY
+(a project key), DIFFERENTLY_POOL (default opensandbox-microvm)."""
+import json, os, time, urllib.error, urllib.request
+
+BASE = os.environ.get("DIFFERENTLY_URL", "http://localhost:8080").rstrip("/")
+POOL = os.environ.get("DIFFERENTLY_POOL", "opensandbox-microvm")
+
+
+def call(method, path, body=None, timeout=1800):
+ data = None if body is None else json.dumps(body).encode()
+ req = urllib.request.Request(BASE + path, data=data, method=method, headers={
+ "Authorization": "Bearer " + os.environ["DIFFERENTLY_API_KEY"], "Content-Type": "application/json"})
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ raw = r.read()
+ return r.status, (json.loads(raw) if raw else None)
+ except urllib.error.HTTPError as e:
+ raw = e.read()
+ try:
+ return e.code, json.loads(raw)
+ except ValueError:
+ return e.code, raw.decode(errors="replace")
+
+
+def create(ttl=7200):
+ return call("POST", "/v1/sandboxes", {"placement": {"allowedPools": [POOL], "external": "never"},
+ "ttlSeconds": ttl, "idleTimeoutSeconds": -1, "labels": {"purpose": "pause-runs"}})
+
+
+def wait(sid, status, timeout=1800):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ code, s = call("GET", "/v1/sandboxes/" + sid)
+ if code == 200 and s.get("status") == status:
+ return s
+ if code == 200 and s.get("status") in ("failed", "terminated"):
+ raise SystemExit(f"{sid} is {s.get('status')}: {s.get('statusReason')}")
+ time.sleep(0.25)
+ raise SystemExit(f"{sid}: timed out waiting for {status}")
+
+
+def exe(sid, cmd, timeout=600):
+ code, r = call("POST", f"/v1/sandboxes/{sid}/exec", {"command": cmd, "timeoutSeconds": timeout}, timeout=timeout + 30)
+ if code != 200:
+ raise SystemExit(f"exec {code}: {r}")
+ return r
diff --git a/test/pause/drop-checkpoints.sh b/test/pause/drop-checkpoints.sh
new file mode 100644
index 0000000..b71b496
--- /dev/null
+++ b/test/pause/drop-checkpoints.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+# Remove consumed checkpoint sets from Fast Sandbox's node image cache (what
+# its cache GC does): entries whose manifest records a checkpoint owner.
+# Templates and golden images stay. Run as root, and only with no sandbox
+# alive: a sandbox resumed from a checkpoint needs that checkpoint's
+# manifest to take its next one.
+cd /var/lib/fast-sandbox/firecracker/images || exit 1
+for d in */; do
+ d=${d%/}
+ if grep -q '"checkpoint"' "$d/manifest.json" 2>/dev/null; then rm -rf "$d" && echo "dropped $d"; fi
+done
+df -h /var/lib/fast-sandbox | tail -1
diff --git a/test/pause/guest-state.sh b/test/pause/guest-state.sh
new file mode 100644
index 0000000..022283e
--- /dev/null
+++ b/test/pause/guest-state.sh
@@ -0,0 +1,22 @@
+# Prints a fingerprint of the guest: long-lived processes (pid, start, name),
+# their open files, and checksums of memory and disk contents.
+pids=$(for p in /proc/[0-9]*; do n=$(cat $p/comm 2>/dev/null); case "$n" in Xvfb|openbox|soffice.bin|chromium|python3|execd|sandbox-init) echo ${p#/proc/};; esac; done)
+for p in $pids; do
+ set -- $(sed 's/^.*) //' /proc/$p/stat 2>/dev/null); st=${20}
+ [ -n "$st" ] && echo "proc $p $(cat /proc/$p/comm) $st"
+done | grep -v " python3 .*" > /tmp/procs.txt
+hp=$(cat /root/holder.pid 2>/dev/null)
+[ -n "$hp" ] && echo "proc $hp holder $(sed 's/^.*) //' /proc/$hp/stat | awk '{print $20}')" >> /tmp/procs.txt
+sort /tmp/procs.txt
+echo "procs_sha $(sort /tmp/procs.txt | sha256sum | cut -c1-16)"
+# open files of the desktop apps (execd's own exec connections change per call)
+fpids=$(for p in $pids $hp; do [ "$(cat /proc/$p/comm 2>/dev/null)" = execd ] || echo $p; done)
+for p in $fpids; do ls -l /proc/$p/fd 2>/dev/null | awk -v p=$p 'NR>1{print p, $NF}'; done | sort > /tmp/fds.txt
+sed 's/^/fd /' /tmp/fds.txt
+echo "fds_count $(wc -l < /tmp/fds.txt)"
+echo "fds_sha $(sha256sum < /tmp/fds.txt | cut -c1-16)"
+if [ -n "$hp" ]; then rm -f /root/holder.check; kill -USR1 $hp; for i in 1 2 3 4 5 6 7 8 9 10; do [ -s /root/holder.check ] && break; sleep 0.5; done
+ echo "holder_mem $(cut -c1-16 /root/holder.sha) $(cut -c1-16 /root/holder.check)"; fi
+[ -f /tmp/marker.bin ] && echo "tmpfs_sha $(sha256sum /tmp/marker.bin | cut -c1-16)"
+[ -f /workspace/data/rand-0.bin ] && echo "disk_sha $(sha256sum /workspace/data/rand-0.bin | cut -c1-16)"
+echo "boot_id $(cat /proc/sys/kernel/random/boot_id)"
diff --git a/test/pause/guest-use.sh b/test/pause/guest-use.sh
new file mode 100644
index 0000000..2f20a85
--- /dev/null
+++ b/test/pause/guest-use.sh
@@ -0,0 +1,40 @@
+# Runs inside the desktop guest. "setup" starts a working desktop session;
+# "step N" adds a little more use before each pause.
+export DISPLAY=:99 HOME=/root
+case "$1" in
+setup)
+ chmod 0755 /run 2>/dev/null
+ mkdir -p /workspace/data /root/chr
+ setsid Xvfb :99 -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 < /dev/null &
+ sleep 1
+ command -v openbox >/dev/null && setsid openbox >/dev/null 2>&1 < /dev/null &
+ setsid soffice --writer --norestore --nologo >/dev/null 2>&1 < /dev/null &
+ setsid chromium --no-sandbox --no-first-run --disable-gpu --user-data-dir=/root/chr \
+ file:///usr/share/doc/ file:///usr/share/common-licenses/ >/dev/null 2>&1 < /dev/null &
+ # an app holding state in memory: 384 MiB of random bytes with its checksum
+ cat > /root/holder.py <<'PY'
+import hashlib, os, signal, time
+buf = bytearray(os.urandom(384 << 20))
+open("/root/holder.sha", "w").write(hashlib.sha256(buf).hexdigest())
+def check(*_):
+ open("/root/holder.check", "w").write(hashlib.sha256(buf).hexdigest())
+signal.signal(signal.SIGUSR1, check)
+while True:
+ time.sleep(3600)
+PY
+ setsid python3 /root/holder.py >/dev/null 2>&1 < /dev/null &
+ echo $! > /root/holder.pid
+ head -c 256M /dev/urandom > /tmp/marker.bin # tmpfs: guest RAM
+ head -c 300M /dev/urandom > /workspace/data/rand-0.bin # user files on disk
+ cp -r /usr/share/doc /workspace/data/doc-0 2>/dev/null
+ find /usr -xdev -type f -size -4M -print0 2>/dev/null | xargs -0 cat > /dev/null 2>&1 # page cache
+ sleep 15
+ ;;
+step)
+ head -c 64M /dev/urandom > /workspace/data/rand-$2.bin
+ cp -r /usr/share/doc/python3* /workspace/data/ 2>/dev/null
+ sync
+ ;;
+esac
+free -m | awk '/Mem/{print "guest mem used MiB", $3, "cache MiB", $6}'
+df -m / | awk 'NR==2{print "guest disk used MiB", $3}'
diff --git a/test/pause/phases.py b/test/pause/phases.py
new file mode 100644
index 0000000..fb4eb92
--- /dev/null
+++ b/test/pause/phases.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Split one Fast Sandbox pause into phases from its components' logs
+(controller, fastlet, runtime agent, Garage) and the sampler's disk trace.
+usage: phases.py SINCE_ISO FAST_SANDBOX_NAME [SAMPLES.jsonl] (root, on the node)
+Prints one JSON object: timestamps, phase durations (s), bytes, disk traffic."""
+import datetime as dt, json, os, re, subprocess, sys
+
+since, name = sys.argv[1], sys.argv[2]
+samples = sys.argv[3] if len(sys.argv) > 3 else None
+K = ["k3s", "kubectl"]
+SYSTEM = os.environ.get("FSB_SYSTEM_NAMESPACE", "opensandbox-system")
+DATAPLANE = os.environ.get("FSB_DATAPLANE_NAMESPACE", "opensandbox-dataplane")
+YEAR = dt.datetime.now(dt.timezone.utc).year # klog lines carry no year
+
+def logs(ns, target, container=None):
+ cmd = K + ["-n", ns, "logs", target, "--since-time", since, "--timestamps=false"]
+ if container:
+ cmd += ["-c", container]
+ return subprocess.run(cmd, capture_output=True, text=True).stdout.splitlines()
+
+def pod(ns, prefix):
+ out = subprocess.run(K + ["-n", ns, "get", "pods", "-o", "name"], capture_output=True, text=True).stdout.split()
+ return next(p for p in out if p.split("/", 1)[1].startswith(prefix))
+
+KLOG = re.compile(r"^[IWE](\d\d)(\d\d) (\d\d:\d\d:\d\d\.\d+)")
+def kt(line):
+ m = KLOG.match(line)
+ if not m:
+ return None
+ return dt.datetime.strptime(f"{YEAR}-{m[1]}-{m[2]} {m[3][:15]}", "%Y-%m-%d %H:%M:%S.%f").replace(tzinfo=dt.timezone.utc).timestamp()
+
+def kv(line, key):
+ m = re.search(key + r'="?([^" ]+)"?', line)
+ return m[1] if m else None
+
+def dur(s):
+ if s is None:
+ return None
+ total, units = 0.0, {"h": 3600, "m": 60, "s": 1, "ms": 1e-3, "µs": 1e-6, "us": 1e-6, "ns": 1e-9}
+ for num, unit in re.findall(r"([\d.]+)(h|ms|µs|us|ns|m|s)", s):
+ total += float(num) * units[unit]
+ return total
+
+ctl = logs(SYSTEM, "deploy/fast-sandbox-controller")
+fast = logs(DATAPLANE, pod(DATAPLANE, "fsb-pool-fastlet"), "fastlet")
+agent = logs(SYSTEM, pod(SYSTEM, "firecracker-runtime"), "firecracker-agent")
+garage = logs(SYSTEM, "deploy/fsb-garage")
+
+r = {"sandbox": name}
+uid = None
+for l in ctl:
+ if f"Sandbox=\"{DATAPLANE}/{name}\"" in l or f"sandbox=\"{name}\"" in l:
+ uid = uid or kv(l, "sandbox_uid")
+ if "pause checkpoint persisted" in l:
+ r["t_persisted"] = kt(l)
+ elif "sandbox paused; runtime released" in l:
+ r["t_released"] = kt(l)
+req = [kt(l) for l in ctl if "fastpath sandbox pause requested" in l]
+r["sandbox_uid"] = uid
+snap = None
+for l in fast:
+ if uid and f'sandboxID="{uid}"' in l:
+ if "dump starting" in l:
+ r["t_dump_start"], snap = kt(l), kv(l, "snapshotId")
+ elif "paused for dump" in l:
+ r["t_vm_paused"] = kt(l)
+ elif "resumed after dump" in l:
+ r["t_dump_done"] = kt(l)
+ for k in ("pauseAPI", "dumpAPI", "rootfsClone", "rootfsCopy", "resumeAPI"):
+ r[k] = dur(kv(l, k))
+ elif "manifest assembled" in l:
+ r["t_manifest"], r["bytes_logical"] = kt(l), int(kv(l, "sizeBytes"))
+ elif "snapshot published" in l:
+ r["t_published"], r["publish"] = kt(l), dur(kv(l, "publish"))
+if "t_dump_start" in r:
+ before = [t for t in req if t <= r["t_dump_start"] + 0.5]
+ r["t_requested"] = before[-1] if before else None
+for l in agent:
+ if snap and snap in l and "publish started" in l:
+ r["t_upload_start"], digest = kt(l), kv(l, "digest")
+ r["namespace"] = digest[:16]
+ elif r.get("namespace") and r["namespace"] in l and "publish completed" in l:
+ r["t_upload_done"] = kt(l)
+GT = re.compile(r"(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+)Z")
+files = []
+if r.get("namespace"):
+ for l in garage:
+ clean = re.sub(r"\x1b\[[0-9;]*m", "", l)
+ if f"PUT /sandbox-images/publish/{r['namespace']}/" in clean:
+ m = GT.search(clean)
+ t = dt.datetime.strptime(m[1][:26], "%Y-%m-%dT%H:%M:%S.%f").replace(tzinfo=dt.timezone.utc).timestamp()
+ files.append((t, clean.rsplit("/", 1)[1].strip()))
+files.sort()
+ends = [t for t, _ in files[1:]] + [r.get("t_upload_done")]
+r["upload_files"] = {f: round(e - t, 2) for (t, f), e in zip(files, ends) if e}
+
+def d(a, b):
+ return round(r[b] - r[a], 2) if r.get(a) and r.get(b) else None
+r["phases"] = {
+ "request_to_node": d("t_requested", "t_dump_start"),
+ "quiesce_and_disk_clone": d("t_dump_start", "t_vm_paused"),
+ "memory_dump": round(r["dumpAPI"], 2) if r.get("dumpAPI") else None,
+ "checksum_and_manifest": d("t_dump_done", "t_manifest"),
+ "upload": d("t_upload_start", "t_upload_done"),
+ "upload_handoff": round((r.get("t_upload_start") or 0) - (r.get("t_manifest") or 0), 2) if r.get("t_upload_start") else None,
+ "metadata_commit": d("t_published", "t_released"),
+ "node_total": d("t_dump_start", "t_published"),
+}
+if samples and r.get("t_dump_start"):
+ rows = [json.loads(x) for x in open(samples)]
+ marks = [("memory_dump", "t_vm_paused", "t_dump_done"), ("checksum_and_manifest", "t_dump_done", "t_manifest"),
+ ("upload", "t_upload_start", "t_upload_done"), ("metadata_commit", "t_published", "t_released")]
+ traffic = {}
+ for label, a, b in marks:
+ if r.get(a) and r.get(b):
+ sel = [x for x in rows if r[a] < x["t"] <= r[b] + 1]
+ cpu = {}
+ for x in sel:
+ for k, v in x["cpu"].items():
+ cpu[k] = round(cpu.get(k, 0) + v, 1)
+ traffic[label] = {"read_mb": round(sum(x["rd_mb"] for x in sel)), "write_mb": round(sum(x["wr_mb"] for x in sel)),
+ "disk_busy_pct": round(100 * sum(x["busy_ms"] for x in sel) / max(1, 1000 * len(sel))), "cpu_s": cpu}
+ r["disk"] = traffic
+print(json.dumps(r))
diff --git a/test/pause/punch.py b/test/pause/punch.py
new file mode 100644
index 0000000..0ad747c
--- /dev/null
+++ b/test/pause/punch.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+"""Turn the all-zero 64 KiB runs of a file into holes, in place: what Fast
+Sandbox's pull would write with the sparse-pull patch. Content is unchanged.
+usage (root): punch.py FILE..."""
+import ctypes, os, sys
+libc = ctypes.CDLL("libc.so.6", use_errno=True)
+libc.fallocate.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_long, ctypes.c_long]
+PUNCH = 0x02 | 0x01 # FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE
+CHUNK, BUF = 64 << 10, 4 << 20
+ZERO = bytes(CHUNK)
+
+def punch(fd, off, n):
+ if n and libc.fallocate(fd, PUNCH, off, n) != 0:
+ raise OSError(ctypes.get_errno(), "fallocate")
+
+for path in sys.argv[1:]:
+ st = os.stat(path)
+ fd = os.open(path, os.O_RDWR)
+ run_start, run_len, off = None, 0, 0
+ with os.fdopen(fd, "r+b", buffering=0) as f:
+ while True:
+ buf = f.read(BUF)
+ if not buf:
+ break
+ for i in range(0, len(buf), CHUNK):
+ chunk = buf[i:i + CHUNK]
+ if chunk == ZERO[:len(chunk)]:
+ if run_start is None:
+ run_start, run_len = off + i, 0
+ run_len += len(chunk)
+ elif run_start is not None:
+ punch(fd, run_start, run_len); run_start = None
+ off += len(buf)
+ if run_start is not None:
+ punch(fd, run_start, run_len)
+ os.fsync(fd)
+ after = os.stat(path)
+ print(f"{path}: {st.st_size/2**30:.2f} GiB, allocated {st.st_blocks*512/2**30:.2f} -> {after.st_blocks*512/2**30:.2f} GiB")
diff --git a/test/pause/runset.py b/test/pause/runset.py
new file mode 100644
index 0000000..bfa724a
--- /dev/null
+++ b/test/pause/runset.py
@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+"""Run a set: for each SCENARIO:N, create a fresh sandbox through
+Differently, run cycle.py on it N times, delete it.
+usage: runset.py PREFIX idle:3 in-use:5
+PAUSE_DROP_CHECKPOINTS=1 also clears consumed checkpoints from the node's
+image cache after each sandbox is deleted (test hosts with a small disk)."""
+import os, subprocess, sys, time
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+import dly
+prefix = sys.argv[1]
+for spec in sys.argv[2:]:
+ scenario, n = spec.split(":")
+ t0 = time.time()
+ code, s = dly.create()
+ s = dly.wait(s["id"], "running", timeout=900)
+ print(f"[{prefix}-{scenario}] created {s['id']} in {time.time()-t0:.1f} s", flush=True)
+ time.sleep(20)
+ subprocess.run(["python3", os.path.join(HERE, "cycle.py"), s["id"], scenario, n, f"{prefix}-{scenario}"])
+ dly.call("DELETE", "/v1/sandboxes/" + s["id"])
+ time.sleep(15)
+ if os.environ.get("PAUSE_DROP_CHECKPOINTS") == "1":
+ subprocess.run(["sudo", "sh", os.path.join(HERE, "drop-checkpoints.sh")])
+print(f"[{prefix}] done", flush=True)
diff --git a/test/pause/s3bench.py b/test/pause/s3bench.py
new file mode 100644
index 0000000..6e61b71
--- /dev/null
+++ b/test/pause/s3bench.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Upload one file to an S3 store the way Fast Sandbox's agent does (one PUT,
+UNSIGNED-PAYLOAD) or as a parallel multipart upload. stdlib only.
+env: S3_ENDPOINT (http://host:port), S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
+usage: s3bench.py FILE KEY single | multipart PART_MiB CONCURRENCY | delete"""
+import concurrent.futures as cf, datetime, hashlib, hmac, http.client, os, re, sys, time, urllib.parse
+
+EP = urllib.parse.urlparse(os.environ["S3_ENDPOINT"]); BUCKET = os.environ["S3_BUCKET"]
+AK, SK, REGION = os.environ["AWS_ACCESS_KEY_ID"], os.environ["AWS_SECRET_ACCESS_KEY"], os.environ.get("S3_REGION", "us-east-1")
+
+def sign(method, path, query="", payload="UNSIGNED-PAYLOAD", extra=None):
+ now = datetime.datetime.now(datetime.timezone.utc); amz = now.strftime("%Y%m%dT%H%M%SZ"); day = amz[:8]
+ headers = {"host": EP.netloc, "x-amz-date": amz, "x-amz-content-sha256": payload, **(extra or {})}
+ names = sorted(headers)
+ canon = "\n".join([method, urllib.parse.quote(path), query, "".join(f"{k}:{headers[k]}\n" for k in names), ";".join(names), payload])
+ scope = f"{day}/{REGION}/s3/aws4_request"
+ sts = "\n".join(["AWS4-HMAC-SHA256", amz, scope, hashlib.sha256(canon.encode()).hexdigest()])
+ k = ("AWS4" + SK).encode()
+ for part in (day, REGION, "s3", "aws4_request"):
+ k = hmac.new(k, part.encode(), hashlib.sha256).digest()
+ sig = hmac.new(k, sts.encode(), hashlib.sha256).hexdigest()
+ headers["Authorization"] = f"AWS4-HMAC-SHA256 Credential={AK}/{scope}, SignedHeaders={';'.join(names)}, Signature={sig}"
+ return headers
+
+def request(method, path, query="", body=None, length=None):
+ h = sign(method, path, query)
+ if length is not None:
+ h["Content-Length"] = str(length)
+ c = http.client.HTTPConnection(EP.hostname, EP.port, timeout=3600, blocksize=1 << 20)
+ c.request(method, path + ("?" + query if query else ""), body=body, headers=h)
+ r = c.getresponse(); data = r.read(); c.close()
+ if r.status >= 300:
+ raise SystemExit(f"{method} {path}?{query} -> {r.status} {data[:300]}")
+ return r, data
+
+class Section:
+ def __init__(self, f, off, n): self.f, self.off, self.left = f, off, n; f.seek(off)
+ def read(self, size=-1):
+ if self.left <= 0: return b""
+ size = self.left if size < 0 else min(size, self.left)
+ b = self.f.read(size); self.left -= len(b); return b
+
+def main():
+ path, key, mode = sys.argv[1], sys.argv[2], sys.argv[3]
+ obj = f"/{BUCKET}/{key}"
+ if mode == "delete":
+ request("DELETE", obj); return
+ size = os.path.getsize(path); t0 = time.time()
+ if mode == "single":
+ with open(path, "rb") as f:
+ request("PUT", obj, body=f, length=size)
+ else:
+ part = int(sys.argv[4]) << 20; conc = int(sys.argv[5])
+ _, data = request("POST", obj, "uploads=")
+ upload = re.search(rb"(.*?) ", data)[1].decode()
+ def put(i):
+ off = i * part; n = min(part, size - off)
+ with open(path, "rb") as f:
+ r, _ = request("PUT", obj, f"partNumber={i+1}&uploadId={urllib.parse.quote(upload)}", body=Section(f, off, n), length=n)
+ return i + 1, r.getheader("ETag")
+ n = (size + part - 1) // part
+ with cf.ThreadPoolExecutor(conc) as ex:
+ etags = sorted(ex.map(put, range(n)))
+ body = ("" + "".join(f"{i} {e} " for i, e in etags) + " ").encode()
+ request("POST", obj, f"uploadId={urllib.parse.quote(upload)}", body=body, length=len(body))
+ dt = time.time() - t0
+ print(f"{mode} {size/1e6:.0f} MB in {dt:.1f} s = {size/1e6/dt:.0f} MB/s")
+
+main()
diff --git a/test/pause/sampler.py b/test/pause/sampler.py
new file mode 100644
index 0000000..ff15211
--- /dev/null
+++ b/test/pause/sampler.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+"""Sample the state-root disk and the pause path's processes once a second.
+Writes JSON lines {t, rd_mb, wr_mb, busy_ms, cpu: {name: seconds}}, each a
+delta over the last second. Run as root: sampler.py OUT [DEVICE]"""
+import json, os, sys, time
+
+out = open(sys.argv[1], "a", buffering=1)
+dev = sys.argv[2] if len(sys.argv) > 2 else "nvme0n1"
+HZ = os.sysconf("SC_CLK_TCK")
+# Process name prefixes (comm is cut at 15 bytes), most specific first.
+NAMES = [("firecracker-run", "agent"), ("firecracker", "firecracker"), ("garage", "garage"), ("fastlet", "fastlet")]
+
+
+def disk():
+ for line in open("/proc/diskstats"):
+ f = line.split()
+ if f[2] == dev:
+ return int(f[5]) * 512, int(f[9]) * 512, int(f[12])
+ return 0, 0, 0
+
+
+def procs():
+ ticks = {}
+ for pid in os.listdir("/proc"):
+ if not pid.isdigit():
+ continue
+ try:
+ comm = open(f"/proc/{pid}/comm").read().strip()
+ label = next((v for k, v in NAMES if comm.startswith(k)), None)
+ if label is None:
+ continue
+ st = open(f"/proc/{pid}/stat").read().rsplit(")", 1)[1].split()
+ ticks[pid] = (label, int(st[11]) + int(st[12]))
+ except (FileNotFoundError, ProcessLookupError, PermissionError, IndexError):
+ pass
+ return ticks
+
+
+pr, pw, pb = disk()
+pc = procs()
+while True:
+ time.sleep(1)
+ r, w, b = disk()
+ c = procs()
+ cpu = {}
+ for pid, (label, t) in c.items():
+ cpu[label] = cpu.get(label, 0) + (t - pc.get(pid, (label, 0))[1]) / HZ # a new process counts from 0
+ out.write(json.dumps({"t": round(time.time(), 3), "rd_mb": round((r - pr) / 1e6, 2), "wr_mb": round((w - pw) / 1e6, 2),
+ "busy_ms": b - pb, "cpu": {k: round(v, 2) for k, v in cpu.items()}}) + "\n")
+ pr, pw, pb, pc = r, w, b, c
diff --git a/test/pause/summarize.py b/test/pause/summarize.py
new file mode 100644
index 0000000..a3ca4de
--- /dev/null
+++ b/test/pause/summarize.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""Summarize pause/resume runs (cycle.py output) as Markdown tables.
+usage: summarize.py RUN.jsonl..."""
+import json, math, statistics, sys
+
+def pct(xs, q):
+ xs = sorted(x for x in xs if x is not None)
+ if not xs:
+ return None
+ return xs[max(0, min(len(xs) - 1, math.ceil(q * len(xs)) - 1))]
+
+def fmt(x, unit=" s", nd=1):
+ return "–" if x is None else f"{x:.{nd}f}{unit}"
+
+def at_floor(r):
+ """A burstable disk out of credits: a phase kept the disk busy but moved
+ far less than its burst rate (about 175 MB/s on a P6 or P15)."""
+ node = r.get("node") or {}
+ for ph in ("memory_dump", "checksum_and_manifest", "upload"):
+ d, secs = (node.get("disk") or {}).get(ph) or {}, (node.get("phases") or {}).get(ph)
+ if not secs or secs < 10:
+ continue
+ busy, rate = d.get("disk_busy_pct", 0), (d.get("read_mb", 0) + d.get("write_mb", 0)) / secs
+ if (busy >= 95 and rate < 150) or (busy >= 85 and rate < 80):
+ return True
+ return False
+
+
+runs = {}
+for path in sys.argv[1:]:
+ for line in open(path):
+ r = json.loads(line)
+ tag = r["tag"] + (" (disk at its floor)" if at_floor(r) else "")
+ runs.setdefault(tag, []).append(r)
+
+print("| Run | Cycles | Pause median | Pause p95 | Resume median | Resume p95 | Resume to first exec, median | State kept |")
+print("|---|---:|---:|---:|---:|---:|---:|---|")
+for tag, rs in runs.items():
+ kept = sum(1 for r in rs if r.get("procs_kept") and all(r.get("state_kept", {}).values()))
+ p = [r["pause_s"] for r in rs]; q = [r["resume_s"] for r in rs]; e = [r["resume_to_exec_s"] for r in rs]
+ print(f"| {tag} | {len(rs)} | {fmt(statistics.median(p))} | {fmt(pct(p, .95))} | {fmt(statistics.median(q), nd=2)} | {fmt(pct(q, .95), nd=2)} | {fmt(statistics.median(e), nd=2)} | {kept}/{len(rs)} |")
+
+phases = ["quiesce_and_disk_clone", "memory_dump", "checksum_and_manifest", "upload", "metadata_commit", "node_total"]
+print()
+print("| Run | " + " | ".join(phases) + " |")
+print("|---|" + "---:|" * len(phases))
+for tag, rs in runs.items():
+ cells = []
+ for ph in phases:
+ xs = [(r.get("node") or {}).get("phases", {}).get(ph) for r in rs]
+ cells.append(f"{fmt(statistics.median([x for x in xs if x is not None]) if any(x is not None for x in xs) else None, nd=2)} / {fmt(pct(xs, .95), '', 1)}")
+ print(f"| {tag} | " + " | ".join(cells) + " |")
+
+print()
+print("| Run | Phase | Disk read MB | Disk write MB | MB/s through disk | Busy | Garage CPU s |")
+print("|---|---|---:|---:|---:|---:|---:|")
+for tag, rs in runs.items():
+ for ph in ["memory_dump", "checksum_and_manifest", "upload", "metadata_commit"]:
+ rd = [((r.get("node") or {}).get("disk", {}).get(ph) or {}).get("read_mb") for r in rs]
+ wr = [((r.get("node") or {}).get("disk", {}).get(ph) or {}).get("write_mb") for r in rs]
+ bz = [((r.get("node") or {}).get("disk", {}).get(ph) or {}).get("disk_busy_pct") for r in rs]
+ gc = [(((r.get("node") or {}).get("disk", {}).get(ph) or {}).get("cpu_s") or {}).get("garage") for r in rs]
+ du = [(r.get("node") or {}).get("phases", {}).get(ph) for r in rs]
+ if not any(x is not None for x in rd):
+ continue
+ rate = [((a or 0) + (b or 0)) / d for a, b, d in zip(rd, wr, du) if d]
+ print(f"| {tag} | {ph} | {statistics.median([x for x in rd if x is not None]):.0f} | {statistics.median([x for x in wr if x is not None]):.0f} | {statistics.median(rate) if rate else 0:.0f} | {statistics.median([x for x in bz if x is not None]):.0f}% | {statistics.median([x for x in gc if x is not None]):.0f} |")
+
+print()
+print("| Run | Logical bytes per pause | Garage stored per pause (median) | Upload, rootfs.ext4 / memory.snap (median) |")
+print("|---|---:|---:|---:|")
+for tag, rs in runs.items():
+ lb = [(r.get("node") or {}).get("bytes_logical") for r in rs]
+ gs = [r.get("garage_stored_mb") for r in rs]
+ ur = [((r.get("node") or {}).get("upload_files") or {}).get("rootfs.ext4") for r in rs]
+ um = [((r.get("node") or {}).get("upload_files") or {}).get("memory.snap") for r in rs]
+ def med(xs):
+ xs = [x for x in xs if x is not None]
+ return statistics.median(xs) if xs else None
+ print(f"| {tag} | {fmt(med(lb) / 1e9 if med(lb) else None, ' GB', 2)} | {fmt(med(gs), ' MB', 0)} | {fmt(med(ur))} / {fmt(med(um))} |")