Running on agent-substrate
agentsessions treats compute as a pluggable Runtime backend. agent-substrate
(Google’s OSS “actors on pre-warmed workers” runtime, with RAM+disk memory snapshots) is one such
backend. This document describes how the neutral core runs on real agent-substrate — proven
end-to-end in CI across both capability tiers — and how to reproduce it.
The claim
Section titled “The claim”On a fresh CI runner, in one kind cluster, agentsessions runs on a real ate-system (not a mock)
across both capability tiers, with the tamper-evident chain verifying across the snapshot boundary, the
gRPC harness transport unchanged, and the core importing zero substrate code:
STATELESS_REPLAYon a micro-VM: place the echo harness, drive a turn, and replay the journal byte-identically with zero model invocations; the hash chain verifies.REQUIRES_MEMORY_SNAPSHOTon a micro-VM (kata + cloud-hypervisor): place the in-RAM counter, drive it to N, suspend (memory snapshot) → restore, and the count continues to N+1 — in-RAM state no journal replay could reconstruct, and a plain pod structurally cannot preserve.- Fork of a stateful session: fan out k children from one parent checkpoint and every child continues at N+1 independently, with the parent still resumable at the same base.
All of it runs in the substrate-conformance job (.github/workflows/substrate-e2e.yml). Both tiers
run on the micro-VM sandbox class, so the suite exercises the same isolation class in both and covers
the micro-VM path end to end.
Neutrality by construction
Section titled “Neutrality by construction”The core must not depend on substrate. The seam is a ControlClient interface defined in the core
(runtime/substrate), a small subset of substrate’s ate-api Control gRPC:
CreateActor · ResumeActor · SuspendActor · DeleteActor · GetActorSnapshotCloner is an optional second interface, also defined in the core, for the clone-from-snapshot
half of fork (CreateTag, CreateActor{source_tag}, DeleteTag). Keeping it separate means a control
client without substrate’s Tag APIs still satisfies the base interface.
runtime/substrate(in the core module) implementsapi.Runtimeover that interface — no substrate import.integrations/substrateis a separate Go module with its owngo.modthat adapts the real generated ate-api client toControlClient. The core never imports it.- A CI gate asserts
go list -deps ./...on the core contains zeroagent-substratepackages.
SPI → substrate mapping
Section titled “SPI → substrate mapping”api.Runtime |
substrate Control |
|---|---|
Create |
CreateActor + ResumeActor (starts from the template’s golden snapshot, or cold-boots) |
Snapshot(EXTERNAL) |
SuspendActor (RAM+disk snapshot to storage, worker freed, actor SUSPENDED) |
Restore |
ResumeActor (restores the actor’s own snapshot on a possibly-different worker) |
Fork (stateless) |
CreateActor + ResumeActor — a replay-fork; the journal reconstructs the child |
Fork (memory) |
CreateTag{source_actor} + CreateActor{source_tag} + ResumeActor — clones the parent’s RAM |
Stop |
SuspendActor + DeleteActor |
Status |
GetActor |
There is no per-resume boot flag (substrate removed ResumeActorRequest.boot). Substrate picks the
source itself: the actor’s own snapshot, else the template’s golden snapshot, else a cold boot of the
template spec. That is enough for STATELESS_REPLAY: a new actor has no snapshot of its own, and the
golden snapshot is the harness captured right after it became ready, before any session touched it,
so it carries no session state. A template that must cold-boot on every resume would set
snapshotConfig.preferredFidelity: SNAPSHOT_FIDELITY_VOLUMES instead; nothing here needs that.
Capabilities.MemorySnapshot = true is the tier that lets substrate host a REQUIRES_MEMORY_SNAPSHOT
harness a filesystem-only backend (runtime/local, a plain pod) refuses via CanPlace.
Fork: cloning an actor from a durable snapshot
Section titled “Fork: cloning an actor from a durable snapshot”Every successful SuspendActor leaves the actor holding a new external snapshot: a new generation in
Actor.status.snapshots whose durable copy is COMPLETED. The adapter reports the highest such
generation’s URI as the held snapshot, the same one ResumeActor restores and CreateTag copies.
CreateTag{source_actor} copies the snapshot a SUSPENDED actor holds into tag-owned storage, and
CreateActor{source_tag} seeds a new actor from that copy. That is the compute half of a session
fork.
There is no Fork RPC to wait for. The Control service exposes no method that forks or clones an
actor directly; tags are the primitive it provides. That is the right half of the split: the compute layer copies a sandbox,
while branching the log, holding the fence, and recording lineage stay session-layer concerns this
repo owns. A session fork is therefore something to build here, not something to request upstream.
How the session layer uses it:
- A
STATELESS_REPLAYfork stays a replay-fork. The parent’s RAM holds nothing the journal lacks, so the child cold-boots and replays. The parent keeps its worker and its log is untouched. - A
REQUIRES_MEMORY_SNAPSHOTfork must clone. The parent’s live state exists only in RAM and the harness never rebuilds it fromHistory(I4), so cold-booting the child would silently produce a divergent branch. The Placer checkpoints the parent, records the ref in a SUSPEND event on the chain, and clones each child from it. - A fan-out shares one checkpoint.
Fork(count: N)snapshots the parent once and clones N children from that single snapshot, so the whole fleet branches from identical state. A turn on the parent would wake it and replace that snapshot, so the Placer refuses one withErrCheckpointing(ABORTED) until the last child is cloned. A second stateful fork or aSuspendof the parent in that time is refused the same way, before it fences or snapshots anything, so it can neither fail the first fork’s SUSPEND record nor replace its snapshot; a fork that arrives while aSuspendruns is refused too. A fan-out that fails partway releases the parent before it tears the cloned children down.
Constraints inherited from substrate, enforced or surfaced rather than papered over:
CreateActorseeds an actor only from a tag, and a tag names the suspended parent actor, capturing whichever snapshot it holds when the tag is created. The router resumes an actor on any request addressed to it, so the parent can be woken and suspended again between the fork’s checkpoint and its tag. The backend checks on both sides of the tag that the parent is still SUSPENDED holding the snapshot the fork took (a suspend always writes a new one), and otherwise deletes the tag and fails withErrSnapshotSuperseded. That error wrapsapi.ErrSnapshotSuperseded, whichForkreports as ABORTED; the caller retries at the parent’s new head. The Placer’s hold on the parent starts when the fork installs its checkpoint mark and covers the turns thisplacement.Registrydrives. It does not stop a request from outside this Registry, or a turn whoseRuntime.CreateorRuntime.Restorewas already in flight when the mark was installed; this check is what catches those.- The backend takes one tag per child (
fork-<child-uid>, atespace-scoped). Each tag owns a full copy of the snapshot, and a child borrows its tag’s copy until its own first suspend, so the tag lives as long as the child andStopdeletes it after deleting the child. - A clone requires the snapshot’s exact source
ActorTemplate, and templates with external volumes are rejected. - A memory clone reflects the parent’s RAM now, so it can only realize a fork at the log head.
Forking a stateful session at a historical seq is refused with
ErrUnplaceable→FailedPreconditioninstead of pairing an old prefix with present-day RAM. - A control client without the Tag APIs does not satisfy the optional
SnapshotClonerinterface; a stateful fork there fails loudly rather than degrading to a lossy replay-fork. - The child fan-out is all-or-nothing. If a child fails to provision, the ones already created are torn down and their tags released before the error returns, since their UIDs are never handed to the caller and an orphan would be unreachable. Teardown runs on a context detached from the caller’s, because the likeliest cause of a mid-fan-out failure is the caller’s deadline expiring.
- The parent checkpoint is not part of that rollback, because it cannot be. Once a stateful fork
reaches the point of snapshotting, the parent is cold with a SUSPEND event on its chain whether or
not the children go on to provision. That is a recoverable state (
Resumebrings the parent back), but a retry must fork at the new head rather than the original seq. - The fork checkpoint is committed under a fence and a CAS on the seq the caller validated, so a turn
racing the fork can never widen the children’s prefix past the RAM they were cloned from. A fork
that is refused takes the fence path not at all: a request that changes nothing must not
supersede a turn in flight on the parent. A fork that proceeds does supersede it, since the
checkpoint frees the parent’s worker. Unlike
Suspend,Forkis not covered by the Registry’s session guard;Suspendrejects overlap instead of interrupting the running turn. The one rough edge is a fork that mints its fence and then aborts on the re-check: it supersedes the writer that beat it even though it gives up. Closing that needs a read-only fence accessor oneventlog.Store(NewFenceis currently the only way to obtain one, and it mutates), which is a wider API change than this path warrants. - Ending the parent’s harness streams sits between two fences. The first stops an in-flight turn
from writing an
ERRORfor the interruption; a turn that mints a newer fence in that window sees that the checkpoint claimed its connection and writes nothing. Ending a stream only cancels the turn, so the checkpoint then waits, bounded by the caller’s context, until every ended turn has made its fence call or returned. Only then is the second fence minted, the one the SUSPEND record is written under, so no turn this Registry drives can fail the record after the parent is already cold. The connections and the checkpoint mark are per session and shared by every Placer of aplacement.Registry, including oneRegistry.Addregisters later, so a turn routed to a different harness byExecRequest.harnessis covered. If the context ends first, the fork aborts before it snapshots. A turn driven by another process is outside this: only the log fences it.
Two properties make the failure paths above safe, both read off substrate’s suspend/delete workflows
(cmd/ateapi/internal/controlapi/) rather than assumed:
SuspendActoris idempotent on an already-SUSPENDED actor. Every step after the load fast-forwards on itsIsCompletecheck, so the call is a no-op that returns the actor still carrying its original external snapshot. Retrying a fork whose fan-out failed after the checkpoint therefore neither errors nor mints a second snapshot — it re-reads the same one.- A clone that never resumed is still deletable.
CreateActorstarts an actor atACTOR_STATE_SUSPENDED, andDeleteActoracceptsSUSPENDED,CRASHED, orDELETING. So the suspend-then-delete teardown the backend runs works on a child that failed atResumeActor, and rollback does not strand an actor or leak its tag.
It is not copy-on-write
Section titled “It is not copy-on-write”RuntimeCapabilities.CoWFork stays false. Each fork tag copies the parent’s snapshot in object
storage, and substrate restores each actor into a private per-actor directory, so an N-way fan-out
costs N snapshot copies and N full restores, and the memory image dominates the transfer. Fork is cheap in the sense that it skips re-deriving state, not in the sense that
children share pages.
The first green run, at the earlier substrate pin where a tag did not copy the snapshot, measured a 3-way fan-out at 5.06s total, ~1.69s per child, on a kind cluster with the counter harness’s small memory image. The suite logs the fan-out time on every run.
Read that as an existence proof, not a bound: it is one snapshot size on one sandbox class. How the cost scales with snapshot size, and whether a paused (node-local) parent changes it, has not been measured.
Placing an actor is idempotent
Section titled “Placing an actor is idempotent”Placer.Exec calls Runtime.Create on every turn, so on substrate Create resolves the actor
rather than assuming it is new — CreateActor rejects a repeat with AlreadyExists, and a cold boot
would discard whatever the actor already holds:
| Actor state | What Create does |
Why |
|---|---|---|
| absent | CreateActor + ResumeActor |
first placement; the actor starts from the template’s golden snapshot, which holds no session state |
RUNNING |
attach, return its incarnation | the second and later turns of a session, and the first turn of a forked child |
SUSPENDED |
ResumeActor |
restores the actor’s own snapshot: the RAM is what must come back |
The forked-child row is the one that matters for this feature. A child’s actor is created from the
parent’s snapshot and resumed before its UID is handed out, so it is already RUNNING when the
first turn lands; cold-booting it there would throw away the cloned RAM the fork exists to carry, and
the harness would never rebuild it (I4). A control-client double that accepts every CreateActor
cannot see any of this, which is why it is asserted against a live cluster.
Suspend and Resume failure boundaries
Section titled “Suspend and Resume failure boundaries”An Exec after Placer.Suspend does not require an explicit Placer.Resume: Create resumes the
SUSPENDED actor, which restores its own snapshot, then the controller records the new execution. This path preserves
the actor’s captured state but does not append a RESUME lifecycle marker.
Placer.Resume restores compute before dialing the harness, minting a fence, recovering any interrupted
turn, and appending RESUME. If a step after Restore fails, the actor may already be RUNNING while the
journal’s last lifecycle marker is still SUSPEND. There is no automatic re-snapshot on this failure.
A later successful Suspend checkpoints the running actor and records a new SUSPEND; a later successful
Exec attaches to it and records a new execution without a RESUME marker. Either reconciles the
recorded compute state with the actor; neither rolls back effects from a failed interrupted turn.
The transport: harnesswire through atenet-router
Section titled “The transport: harnesswire through atenet-router”The harness is a harnesswire gRPC server on port 80 in the actor. The host reaches it the only way
current substrate supports: through atenet-router, the cluster’s actor ingress.
- Every substrate incarnation carries the router address (
atenet-router.ate-system.svc:80by default,substrate.WithRouterto override) andCallMetadataofate-target-actor: <atespace>/<actor>. The Placer’s dialer attaches that metadata to every call. The router ignores the host and authority; the header alone selects the actor (substrateinternal/atenet/headers.go). - The router accepts h2c and carries gRPC unary, server-streaming and bidi calls, trailers included,
to the actor (substrate #1183; upstream e2e
TestIngressGRPC). It resumes a suspended actor on request, then forwards over mTLS to the worker’s atunnel, which checks the actor is assigned to it and proxies to port 80 over the actor’s private veth. - Substrate removed the worker pod-IP:80 ingress on purpose (substrate #559), so there is no direct path to fall back on, and the conformance Job needs no NetworkPolicy exception.
flowchart LR job["conformance Job<br/>in-cluster `go test`"] api["ate-api-server"] router["atenet-router<br/>(Envoy + ext_proc)"] tunnel["atunnel on the worker"] actor["actor harness :80<br/>gVisor / micro-VM"] job -->|"Control gRPC<br/>Service + SA token"| api job -->|"Harness.Connect h2c<br/>ate-target-actor"| router router -->|"ResumeActor if needed"| api router -->|"mTLS h2"| tunnel tunnel -->|"h2c over veth"| actor
Limits that come with the router, measured or read rather than assumed:
- Route timeout. The router sets
--route-timeout(5m by default, one global value) and a route-level stream idle timeout 30s past it (routeIdleTimeoutincmd/atenet/internal/router/xds.goatfc0e3586).TestHarnessStreamIdlePastRouteTimeoutholds a Connect stream idle, as a turn parked on a slow model call does. On kind, with the echo harness on gVisor, the router tore the stream down after 5m30s, both at362637f9and atfc0e3586(held idle for 6m30s): a pendingRecvgotInternal“stream terminated by RST_STREAM with error code: INTERNAL_ERROR”, not a 504. Through the Placer, which does not read while the host’s model call runs, the turn failed only when the host sent its late reply, with a bareEOF(measured at362637f9). So a turn that runs longer than the route timeout fails once its stream has been idle past the idle timeout (5m30s by default). Until substrate offers a per-route or streaming timeout (tracked in substrate issue 1291), raise the router’s--route-timeoutabove the longest turn; the idle timeout follows it. - Checkpoint drain. Before it snapshots, the worker waits for the actor’s in-flight requests to
finish, and an idle open Connect stream is one of them. On kind with the echo harness on gVisor, at
362637f9,SuspendActorunder such a stream took 5m30s: it waited for the router to reset the stream.Placer.Suspendtherefore never checkpoints under a turn it knows of: while anExecorResumeof the session runs, it is refused withErrSessionBusy(ABORTED), and once the turn has returned its stream is closed. A statefulFork, which supersedes the running turn instead, closes the parent’s harness streams before it checkpoints.TestSuspendUnderAnIdleHarnessStreamchecks the Suspend half on a live cluster. Both cover turns any Placer of this process’splacement.Registrydrives; a turn driven outside this Registry is fenced by the log, but its stream stays open until it next touches the log and can still stall a checkpoint. - No caller authentication. The router does not check who is calling before it resumes an actor and proxies to it, so any pod that can reach it can drive any harness. See Security posture.
The suite (integrations/substrate/e2e)
Section titled “The suite (integrations/substrate/e2e)”The conformance suite is an ordinary Go test package, compiled with go test -c and run as an
in-cluster Job. A capability is a Test function, not a bespoke driver binary: adding one means
writing a test, not writing another main, another Job manifest, and another copy of the polling
shell. Tests skip unless AGENTSESSIONS_E2E=1, so the cheap per-PR job still compiles every line of
the suite — a driver that no longer builds fails in seconds rather than 15 minutes in.
TestStatelessReplay — stateless-replay
Section titled “TestStatelessReplay — stateless-replay”Places the echo harness (STATELESS_REPLAY, on a micro-VM unless ECHO_SANDBOX_CLASS=gvisor)
through the Placer, drives one turn through the router, then re-dials and replays. Asserts: replay
is byte-identical, model invocations are 0 (I1), and the hash chain verifies. Same determinism
triple as the unit conformance suite, now through the substrate router.
TestSessionSuspendResume — session-level stateless suspension
Section titled “TestSessionSuspendResume — session-level stateless suspension”Places the echo harness through the Placer, executes a turn, calls Placer.Suspend and explicit
Placer.Resume, then executes another turn. The recorded snapshot retains the actor handle required
by Restore, and both lifecycle markers and subsequent output remain on a valid hash chain. This
regression is selected alongside stateless replay in the stateless-tier CI pass.
Cold suspension is not teardown: Snapshot(EXTERNAL) releases the worker and keeps the actor
SUSPENDED; Stop deletes it. Calling Stop after the snapshot would make explicit Resume fail with
NotFound, even for a stateless harness.
TestMemorySnapshotSuspendResume — memory continuity
Section titled “TestMemorySnapshotSuspendResume — memory continuity”Places the in-RAM counter (harness/counteragent, REQUIRES_MEMORY_SNAPSHOT) on the micro-VM class:
- Drive to N through the
Placer(count=N lives in guest RAM, not the journal). Placer.Suspend→Snapshot(EXTERNAL): memory snapshot to storage, worker freed (SuspendActor), actor retained, and SUSPEND recorded on the chain.- Explicit
Placer.Resumerestores the actor and records RESUME. - Drive one more turn through the Placer without booting over the restored RAM.
Asserts:
- Continuity — the counter returns N+1: the in-RAM state survived the snapshot round-trip.
- No double-application — the value is N+1, not 2N+1: the journal was not replayed into restored
RAM (I4 — the counter never reconstructs state from
Start.History). - Provenance — the hash chain still verifies across the SUSPEND and RESUME boundary.
Both lifecycle operations use the Placer, rather than bypassing it with a raw Runtime snapshot and manually appended SUSPEND. Neither operation tears down the actor needed to restore its RAM.
TestForkFanOutFromMemorySnapshot — branching a fleet from a base session
Section titled “TestForkFanOutFromMemorySnapshot — branching a fleet from a base session”Drives the counter to N, fans out to k children in one Placer.Fork call, then gives each child a
turn. Every child must answer N+1, which is two assertions at once:
- N+1 rather than 1 means each child really restored the parent’s RAM. A child that cold-booted answers 1 — the silent divergence a stateful fork exists to prevent.
- N+1 from every child, rather than N+1, N+2, N+3 in the order they were driven, means the children are independent: they branched from identical state and evolved separately.
It also asserts each child’s inherited chain verifies and its head is past the fork seq (the copied
prefix plus the FORK marker), then resumes the parent and requires it to continue at N+1 too — a
fork checkpoints its parent, it does not consume it. The fan-out is timed and logged, so a run reports
the real per-child clone cost rather than leaving it asserted.
TestForkOfMemoryHarnessAtHistoricalSeqIsRefused pins the honest-degradation half on real compute: a
stateful fork behind the head is refused, and the refusal leaves the parent usable.
TestConcurrentSessionsReachTheirOwnActors — two sessions through one router
Section titled “TestConcurrentSessionsReachTheirOwnActors — two sessions through one router”Every actor shares the router’s address, so the ate-target-actor metadata on each call is all that
keeps two sessions apart. The test drives two counter sessions to different counts (A to 2, B to 1)
through the Placer, then gives each one more turn at the same time over its own router connection.
The host holds each session’s Output until the other session’s has arrived, so both calls are in
flight through the router together. Asserts: A answers 3 and B answers 2. A call that reached the
other session’s actor would answer with the other count.
placement’s TestConcurrentSessionsThroughOneRouterReachTheirOwnActors makes the same claim
against an in-process router with both turns held inside their model calls, and
TestDefaultDialCallMetadataOverridesTheCaller pins that a caller’s own ate-target-actor value,
in any letter case or number, never reaches the harness call.
The counter’s I4 contract is harness-side and unit-tested: it is increment-only and never reads
Start.History (empty on a memory-restored sandbox). The neutrality contrast (CanPlace refuses the same
capability on runtime/local, accepts on substrate) is placement’s TestNeutralityThroughPlacer.
.github/workflows/substrate-e2e.yml runs the whole suite in one cluster, on every PR and nightly
(heavy — kind + KVM + micro-VM assets — but ~15 min, and the claims it checks are the ones no unit test
can make). A concurrency group cancels a superseded run so one push does not leave two clusters
standing. It copies substrate’s own recipe: create-kind-cluster + install-ate-kind, then
run-microvm-demo-kind before either tier runs (stages the kata + cloud-hypervisor asset cache and
installs the micro-VM SandboxConfig both templates name).
The workflow applies only the namespace and the micro-VM WorkerPools
(deploy/substrate/namespace.yaml, echo-microvm-workerpool.yaml,
counter-microvm-workerpool.yaml). ActorTemplates are substrate API resources in an atespace, so the
suite creates its own through Control with the harness image the workflow built (HARNESS_IMAGE) and
waits for each golden snapshot.
The suite runs in three passes against that one cluster, all through hack/run-e2e-job.sh: the
stateless tier and the suspend-under-an-idle-stream check first, so a break there fails before the
stateful tier runs, then the idle-stream route-timeout check, then the stateful tier, fork and
concurrent sessions. Each pass is the same image with a different -test.run, and the Job’s full
go test -v output is echoed into the step, so a failure names the test and the assertion instead
of surfacing an exit code. The nested-module test and core-neutrality gate also run per-PR in .github/workflows/ci.yml.
Reproduce:
gh workflow run substrate-e2e.yml --ref maingh run watch "$(gh run list --workflow=substrate-e2e.yml --limit 1 --json databaseId --jq '.[0].databaseId')"The micro-VM class needs a Linux host with /dev/kvm; the recipe mirrors the CI steps against
hack/ in an agent-substrate checkout. Each tier picks its class with its own variable,
ECHO_SANDBOX_CLASS and COUNTER_SANDBOX_CLASS: microvm (the default) or gvisor. Any other
value is refused. KIND_CLUSTER_NAME selects the cluster for the hack/ scripts.
On a host without KVM (an arm64 Mac running kind), skip run-microvm-demo-kind, install both gVisor
WorkerPools, and run both tiers with gvisor. The block below starts from a host with only Docker,
kind, kubectl, ko, Go and git installed, and runs from the root of this checkout. It clones
agent-substrate at the commit the workflow pins (SUBSTRATE_REF), creates the kind cluster and its
local registry (localhost:5001) with substrate’s hack/ scripts, and installs ate-system the way
CI does. create-kind-cluster.sh deletes any existing cluster named KIND_CLUSTER_NAME before it
creates one, so pick a name you do not use for anything else.
The pool manifests name the worker image as a ko:// import path, which the WorkerPool controller
passes to its Pods unchanged, so the block builds ateom-gvisor from the same agent-substrate
checkout and substitutes its digest, as CI does for ateom-microvm. Every image must match the
node’s architecture: ko builds linux/amd64 unless KO_DEFAULTPLATFORMS says otherwise, and an
amd64 harness image on an arm64 node fails runsc start.
export KIND_CLUSTER_NAME=agentsessions-e2eexport KO_DOCKER_REPO=localhost:5001 KO_DEFAULTPLATFORMS="linux/$(go env GOARCH)"SUBSTRATE_REF="$(sed -n 's/^ SUBSTRATE_REF: //p' .github/workflows/substrate-e2e.yml)"SUBSTRATE="$(mktemp -d)/substrate"git clone https://github.com/agent-substrate/substrate "${SUBSTRATE}"git -C "${SUBSTRATE}" checkout "${SUBSTRATE_REF}"
# Cluster, local registry and ate-system, as the workflow does.(cd "${SUBSTRATE}" && hack/create-kind-cluster.sh)(cd "${SUBSTRATE}" && ATE_CREDENTIAL_PROVIDER='{"name":"k8s.io"}' ATE_INSTALL_ROLLOUT_TIMEOUT=10m \ hack/install-ate-kind.sh --deploy-ate-system)
# Images: the conformance test binary, the harness and the gVisor worker.E2E_IMAGE=localhost:5001/agentsessions-e2e:localE2E_DIR="$(mktemp -d)"(cd integrations/substrate && CGO_ENABLED=0 GOOS=linux GOARCH="$(go env GOARCH)" go test -c -o "${E2E_DIR}/e2e.test" ./e2e)cp deploy/substrate/Dockerfile.e2e "${E2E_DIR}/Dockerfile"docker build -t "${E2E_IMAGE}" "${E2E_DIR}" && docker push "${E2E_IMAGE}"ATEOM_IMG="$(cd "${SUBSTRATE}" && ko build --base-import-paths ./cmd/ateom-gvisor)"HARNESS_IMAGE="$(ko build ./cmd/harnessnode)"
# The gVisor WorkerPools, then both tiers.kubectl --context "kind-${KIND_CLUSTER_NAME}" apply -f deploy/substrate/namespace.yamlfor tier in echo counter; do sed -e "s#ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor#${ATEOM_IMG}#" \ "deploy/substrate/${tier}-gvisor-workerpool.yaml" | kubectl --context "kind-${KIND_CLUSTER_NAME}" apply -f - hack/wait-worker-pool.sh "${tier}-gvisor"doneHARNESS_IMAGE="${HARNESS_IMAGE}" ECHO_SANDBOX_CLASS=gvisor COUNTER_SANDBOX_CLASS=gvisor \ hack/run-e2e-job.sh 'TestStatelessReplay|TestSessionSuspendResume|TestSuspendUnderAnIdleHarnessStream|TestMemorySnapshotSuspendResume|TestFork|TestConcurrentSessionsReachTheirOwnActors' \ "${E2E_IMAGE}" 1800kind delete cluster --name "${KIND_CLUSTER_NAME}" removes the cluster afterwards. The
kind-registry container is shared by every cluster create-kind-cluster.sh makes, so it stays.
A gVisor MEMORY-fidelity checkpoint also captures memory, so the stateful and fork tests exercise the same code paths on gVisor; the micro-VM run in CI remains the reference for both tiers.
Verification boundary
Section titled “Verification boundary”The session-level stateless regression and explicit memory Resume are selected by the respective
live CI passes. Ordinary local go test runs compile them but skip them unless AGENTSESSIONS_E2E=1;
a skipped test is not live verification.
Root placement tests separately exercise a lifecycle-faithful control fake: deletion removes actors,
missing-actor Resume returns NotFound, Suspend retains established fork-child pins, and destructive
Stop removes only the child’s own pin while its sibling remains usable. Suspend followed by Exec
without Resume restores through Create, which resumes the actor from its own snapshot, preserves the fake’s counter, and journals no
RESUME marker. These tests prove orchestration and retention behavior, not real RAM snapshot continuity.
In progress
Section titled “In progress”- Fork tag lifecycle. A fork tags the parent (
fork-<child-uid>) soCreateActorcan seed the child from it, and each tag owns a full copy of the parent’s snapshot. The tag name is derived from the child session UID, soStopdeletes it after the child and a failed fork deletes it on the way out. A child’s tag is kept indefinitely:DeleteSessionis unimplemented and no session teardown callsStop. Suspended actors are likewise retained indefinitely. Operators must reclaim actors and fork tags directly in Substrate; there is no automatic retention deadline. One tag per fan-out instead of per child would save N-1 copies, but a shared tag can only be deleted once every child has suspended at least once. - Controller-side I4.
controller.Execsends the full journal asStart.Historyeven on the post-restore turn; for the general case the controller should send emptyHistoryon memory-restore. The counter’s harness-side I4 carries the stateful tier today.