Skip to content

feat(runners): long-running agent sessions in their own container - #7487

Draft
joeyorlando wants to merge 12 commits into
mainfrom
jorlando/agent-runners
Draft

feat(runners): long-running agent sessions in their own container#7487
joeyorlando wants to merge 12 commits into
mainfrom
jorlando/agent-runners

Conversation

@joeyorlando

@joeyorlando joeyorlando commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What this is

Generic building blocks for long-running agentic sessions — a Runner is one agent session in its own Kubernetes pod, attachable and steerable while it runs. The goal is that a system like our crab task-environments could be assembled from these pieces inside the product, rather than crab itself being productized.

Behind ARCHESTRA_RUNNERS_ENABLED, an independent switch — a runner carries the credentials of whoever started it, so the ARCHESTRA_BETA master switch must not enable it by implication. The API reports the feature only when the Kubernetes runtime is configured too.

Why Kubernetes and not Dagger

Worth stating, since Dagger was the obvious candidate:

  • Our Dagger backend is a replay executor. Every command materializes a fresh container by replaying the sandbox's whole history, then discards it. The Rust boundary has no stdin, no TTY, no cancellation, no session identity — and the README says it outright: live processes are not durable.
  • Dagger services are scoped to the client session and stop when it disconnects. There is no detached mode. A session that must outlive a backend deploy cannot live there.
  • Dagger's own scale-out already runs as Kubernetes StatefulSets we provision, so there was no Kubernetes to avoid.

Long-running interactive sessions are the negation of the replay model, so Runners ride the pod path — where bidirectional attach already works — and the skills sandbox keeps Dagger untouched, where replay determinism is the feature.

How it works

One Job per session, not a Deployment. A session runs to completion; a Deployment would restart a finished one forever and a crashed one behind the user's back, re-executing side effects the first attempt already had.

tmux is PID 1 in every pod, which is what makes a session attachable and steerable at all. Steering has two modes: a FIFO the Archestra runner-agent reads at a turn boundary, so a message can never interleave with a tool call in flight, and tmux send-keys for bring-your-own CLIs that own their own input loop.

Credentials are declared per agent, at a scope. shared values come from the agent's secret bag; per_user values come from the invoking person's own store, for credentials that carry an individual's identity upstream — a personal Claude token, a personal GitHub PAT. Someone who hasn't supplied one gets a 409 with a machine-readable code and reads the field list from a preflight endpoint, so the UI can prompt them. Those stay separate from shared credentials only an administrator can fix: asking someone for a value they cannot provide is worse than failing.

No proxy or gateway configuration. The LLM proxy URL, a personal-scope virtual key (so spend attributes to the human who started it) and the user's gateway bearer are all derived from the invoking identity. Egress to the platform is a second NetworkPolicy selecting only runner pods, so it composes with the environment's own policy without widening anything for MCP servers.

Five MCP toolsstart_runner, list_runners, get_runner, send_to_runner, stop_runner — so an agent orchestrates sessions by tool call. The identity is always the gateway-authenticated caller, never a tool argument.

Review findings fixed in this PR

Two independent adversarial reviews ran against the branch. Both found real bugs; the last three commits are the fixes.

Privilege escalation, two paths. privileged was a plain field on the agent's runner config with no gate, despite comments claiming one — any member who could edit an agent could grant themselves host root. And runnerSecretId was accepted in the agent request body while no server code ever wrote it, so a member could point an agent at any secret row in the deployment, declare a shared credential naming one of its fields, and have the value injected into a pod whose image they also chose.

No DNS egress. Once any egress policy selects a pod its egress is clamped to the union of selecting policies, and no other policy selects these — so every session would have failed to resolve the platform's own hostname, surfacing as a generic agent failure.

A standing credential per session. The per-runner virtual key was never revoked despite the schema saying it was: a stopped session's key kept working and kept charging its creator.

Terminal-state race. The claiming transitions were compare-and-set but the terminal ones weren't, and only the explicit-stop path took the lease — so one replica could report a session failed while another overwrote that verdict with "stopped".

Also: a failed launch stranded a Secret holding personal credentials; a runner in stopping wedged permanently with a live Job; teardown swallowed transient errors without retrying behind a row claiming success; agent-supplied environment could override the proxy URL the launch path refuses to run without; the MCP start tool skipped the feature gate; a steer message starting with a dash was parsed by tmux as options, and an embedded newline submitted half a message.

Every comment that described behaviour the code did not implement is now either true or gone.

Tests

86 passing, on real Postgres and a real secrets manager, with no mocking of the system under test. They pin the behaviour that matters: compare-and-set transitions that make a reconciler lose to a user's stop, expiry arithmetic, concurrent event-sequence allocation, credential rotation discarding the superseded secret, per-user isolation, the privileged-config gate, and assertions that secret values never appear in a serialized row or a pod spec.

Not in this PR

  • The runner-agent image — the default harness. Bring-your-own-image works today; the default image reference has nothing behind it yet.
  • Attach UI — the WebSocket exec bridge and terminal. The pod-side mechanics are in place (tmux, exec); the bridge and frontend are not.
  • ChatOps thread↔runner binding, so Slack replies steer a session.
  • A convergence sweep for pods whose row is gone, and the pending-forever case where a request dies before the workload is created.
  • A user-facing docs page — the docs standard requires screenshots of the running feature, so it waits for the UI. Deployment environment variables are documented.

Open question for review

start_runner is a trusted Archestra tool, which by convention bypasses tool-invocation policy. That calibration was made for lower-blast-radius tools; this one starts an autonomous container running under the caller's real credentials, with the initial instruction as an argument. If a chat agent is prompt-injected through untrusted tool output, it could start a background session that spends the user's quota out of sight. Worth deciding whether starting a runner should require explicit user confirmation, unlike list and get.


Archestra Contributor

A Runner is one long-running agentic session in its own pod, attachable
and steerable while it runs. These are the generic building blocks a
system like crab-env can be assembled from, rather than that system
productized.

Data model. The runner row is the source of truth and the pod is cattle,
which is the inverse of the skills sandbox (an ordered replay log with
no live process). State transitions are compare-and-set, so a reconciler
pass racing a user's stop loses instead of resurrecting a torn-down
session.

Runtime. Each runner is a Job, not a Deployment: a session runs to
completion, and a Deployment would restart a finished one forever and a
crashed one behind the user's back, re-executing side effects the first
attempt already had. tmux is PID 1 in every pod, which is what makes a
session attachable and steerable at all. Steering has two modes: a FIFO
the Archestra runner-agent reads at a turn boundary, so a message can
never interleave with a tool call in flight, and tmux send-keys for
bring-your-own CLIs that own their own input loop. Cluster mutations
serialize on the existing cluster-lease table under a new scope rather
than a leader election.

Credentials. An agent declares what its runner needs and at which scope.
Shared values come from the agent's secret bag; per-user values come
from the invoking person's own store, for credentials that carry an
individual's identity upstream. A user who has not supplied one gets a
409 with a machine-readable code, and reads the field list from the
preflight endpoint. Those stay separate from shared credentials only an
administrator can fix: asking someone for a value they cannot provide is
worse than failing.

Wiring. A runner needs no proxy or gateway configuration of its own. The
LLM proxy URL, a personal-scope virtual key (so spend attributes to the
human who started it) and the user's gateway bearer are all derived from
the invoking identity. Egress to the platform is a second NetworkPolicy
selecting only runner pods, so it composes with the environment's own
policy without widening anything for MCP servers.

Steer, stop and delete are restricted to the runner's creator or a
runner admin, deliberately narrower than the runner:update permission
that reaches the route: a session runs under its creator's credentials.

ARCHESTRA_RUNNERS_ENABLED is an independent switch rather than one
falling back to ARCHESTRA_BETA, and the API reports the feature only
when the Kubernetes runtime is configured too.
Five Archestra MCP tools — start_runner, list_runners, get_runner,
send_to_runner, stop_runner — so an agent can spin up a long-running
session, watch it, talk to it and shut it down without anyone touching
the API directly. This is what makes the crab-env shape reproducible
from inside the product: a chat agent asked to take on a task starts a
runner for it, then relays progress.

The identity a runner acts as is always the gateway-authenticated
caller, never a tool argument — a model must not be able to start a
session as someone else, and the session carries that person's
credentials. Steering and stopping additionally require having started
the session (or runner:admin), the same rule the REST routes apply.

When a start is blocked only because the caller has not supplied a
credential the agent declares, the tool says which ones and where to add
them, so the agent can tell the human something actionable rather than
relaying an opaque failure.

Route and tool now share one start path, so the identity rules and the
failure handling cannot drift apart.
Fleet-level signals for a feature whose whole cost is holding pods:
how many sessions are alive in each state, how they end, how long
provisioning takes, and how much steering happens. Labelled only by
closed sets — never by runner id, agent, image or user, which would
explode series cardinality; per-runner detail already lives in the
database.

The active gauge is rewritten from each reconcile pass and reports
zeros explicitly, so a state that empties clears instead of holding its
last non-zero sample forever.

Termination outcomes distinguish a completed session from a failure, a
user's stop, and each expiry clock. A runner can have both clocks set,
so the idle one is only reported when it has actually elapsed.
…bjects

A runner image runs arbitrary code holding the caller's credentials, so
it is now held to the same trusted-registry rule an MCP server image
already is. An environment with no configured registries places no
restriction; one with a list admits only those, and anything else is
refused before a workload exists.

Runtime-created objects also carry owner references now, so a release
uninstall collects them instead of leaving orphaned Jobs, Secrets and
network policies behind. Resolution is best-effort: an unavailable role
lookup logs and proceeds rather than blocking a start.
…aults

Review findings, in severity order.

A privileged runner pod holds host devices and full capabilities, so
configuring one is node-level access rather than an agent setting. Two
comments claimed an admin gate that did not exist: any member able to
edit an agent could set the flag and start a runner from it. The flag is
now refused unless the writer holds runner:admin.

runnerSecretId was accepted in the agent request body while nothing
server-side ever wrote it. A member could point an agent at any secret
row in the deployment — another user's personal credential, an MCP
server's env bag — declare a shared credential naming one of its fields,
and have the value injected into a pod whose image they also chose. The
column is now omitted from both agent schemas, so only server code can
set it.

The per-runner virtual key was never revoked despite the schema saying
it was: a stopped session's key kept working and kept charging its
creator. Teardown now revokes it.

A launch that failed after the Secret was written left it behind
holding the caller's personal credentials, because `failed` is terminal
and the reconciler never revisits those rows. The failure path tears
down first.

stop() marked a runner stopped even when the lease was held and nothing
had been removed, hiding a still-running pod from every sweep that would
have caught it. It now stays in `stopping` for the next pass.

Runner pods got no DNS egress. Once any egress policy selects a pod its
egress is clamped to the union of selecting policies, and no other
policy selects these — so every session would have failed to resolve the
platform's own hostname, surfacing as a generic agent failure. Both
resolver shapes the other runtimes handle are now allowed.

Also: the audit invariant test was failing because two tables were
marked audited with no registered route — runner lifecycle already has
its own append-only surface in runner_events, and personal credentials
have no administrative CRUD, so both are now explicitly not audited with
that reasoning. /api/config reported a different predicate than the
routes gate on, so the UI could advertise a feature whose endpoints
answer 404. Agent-supplied environment entries could override the proxy
URL the launch path refuses to run without. The MCP start tool skipped
the feature gate. A steer message beginning with a dash was parsed by
tmux as its own options, and an embedded newline submitted half a
message on both delivery paths.

Every comment that described behaviour the code did not implement is
either now true or gone.
…down

Three faults in the reconciler's state machine, all reachable without
artificial simultaneity because two web replicas sweep on independent
timers.

The claiming transitions were compare-and-set but the terminal ones
were not, and only the explicit-stop path took the lease. So one replica
could conclude a session had failed while another was still tearing the
same session down, and that second replica's unguarded write would
overwrite the verdict — a crashed session reported as cleanly stopped,
with the termination metric counted twice and a timeline that
contradicts itself. Both terminal transitions are now guarded, and the
natural-completion paths take the lease the same way the explicit stop
already did.

A runner in `stopping` was wedged permanently: no branch in the
reconciler advanced one, so a process that died between claiming the
state and tearing down left a Job running with nothing ever collecting
it. `stopping` is now resumable — the next pass retries the teardown and
finishes the transition.

Teardown swallowed every non-404 error without retrying, while callers
went on to write a terminal state regardless. A terminal runner leaves
every sweep, so one transient API error stranded the Job, Secret and
network policy for good behind a row claiming success. Deletions now
retry like the create path does.
The harness a Runner starts from when its agent names no image of its
own. It is deliberately thin: the model, the tool set, the policies and
the budget are all resolved by the platform behind the LLM proxy and MCP
gateway this process talks to, so the loop's own job is to keep a
conversation going, print it legibly for anyone attached to the tmux
session, and take direction from a human without losing its place.

Steering is consumed at a turn boundary, never mid-turn. The control
plane writes one line per message into a FIFO; the loop reads it
continuously but only folds messages into the conversation between
turns, so a steer cannot be spliced into a tool call in flight. With
nothing left to answer the loop parks on that channel rather than
spinning, which is what makes a session that idles for days almost free
— the long-running-monitor case this feature exists for.

The gateway is dialled as an ordinary external MCP client with the
invoking user's bearer. The pod has no privileged path back into the
platform, so the tool set is exactly that person's.

Configuration is read once and nothing is guessed: a missing value names
itself and exits, because a pod started wrong is far easier to diagnose
than an agent quietly talking to the wrong place.

The image installs with pnpm rather than npm so the AI SDK version stays
governed by the workspace catalog instead of a copy that drifts, and it
runs as the base image's existing unprivileged user — an image that only
works as root would push every deployment towards a privileged runner.
The interactive shell already built for MCP servers now serves both: it
keeps xterm lifecycle, fitting, resize and the status chrome, and takes
the connection itself as a prop. The two features speak different
WebSocket messages, but that difference was never part of running a
terminal, so the MCP component is now a thin adapter over the shared one
and the runner component is another.

The terminal reconnects on a session key rather than the transport's
object identity, so a caller that forgets to memoize cannot cause a
reconnect loop.

Attaching runs `tmux attach` rather than opening a shell: the point is
to land in the pane the agent is already working in, seeing its
scrollback and typing into the same session — which is what makes
interjecting possible. Detaching leaves it running, so closing a tab
never ends a session mid-task. Terminal dimensions ride the SPDY resize
channel; without it tmux would keep redrawing to a size nobody is
looking at.

Authorization matches the REST and MCP surfaces: attaching reaches a
shell holding the creator's own credentials, so only they or a runner
admin may do it, and every attach is recorded on the session timeline.

Output is a separate read-only view, because the moment you most want to
read a session is after it failed and there is no live pane left to
attach to.

The detail page mounts the attach only while its tab is showing — a
background tab holding an exec open against a pod is a cost with no
reader.
…nnect

Two things a closer read of the existing MCP viewer turned up.

The log readout was already extracted once, as LogConsole, precisely so
every log surface in the product reads the same — pod logs, connector
sync runs. The runner panel had grown its own near-copy instead, which
would have drifted from the others on the first styling change. It now
uses the shared one and keeps only what is genuinely its own: the
subscription and the follow-the-tail behaviour.

Neither the MCP terminal nor its logs viewer re-subscribed after a
WebSocket reconnect, though the client exposes a connection-change hook
for exactly that reason. The server forgets every subscription along
with the socket it was made on, so a dropped connection left a terminal
that looked live and received nothing until the reader thought to
refresh. Both terminals — the MCP one included, since it now shares the
component — and the runner log panel re-open on the connection edge.
The agent columns arrived a step after the tables only because that is
the order the work happened in; nothing depends on them landing
separately. One migration is what a reader would expect for one feature,
and it is one fewer step to reason about on the way to production.
@joeyorlando
joeyorlando force-pushed the jorlando/agent-runners branch from 57fa85f to 7a939ea Compare August 26, 2026 22:07
The availability gate asked whether the two orchestrator environment
variables were set. The client loader also falls back to the ambient
~/.kube/config, which is how a developer machine runs MCP server pods —
so on exactly the setups where the Kubernetes runtime demonstrably
works, Runners reported themselves unavailable and every endpoint
answered 404.

It now asks whether a client can be built at all, cached because the
answer involves the filesystem. Found by running the page locally: the
API reported the MCP runtime enabled and Runners disabled on the same
deployment, which is not a state that should be reachable.
The runtime worked and the page was empty, because nothing in the
product could make an agent runnable or start a session. Three pieces
close that.

An agent's Advanced settings now carry runner configuration: the image,
command, steering mode, lifetime, and the credentials the session needs
with the scope of each. The privileged toggle only appears to someone
who could actually grant it — the backend refuses it either way, but a
control you cannot use should not be offered.

Runners can be started from the page, and a start that needs a personal
credential asks for it inline rather than failing. The agent declares
what it needs, the person supplies it once, and the runner starts in the
same action — being sent elsewhere to paste a token and come back is the
friction the whole declaration mechanism exists to remove.

Account gains a Credentials section for the same values outside that
flow. They stay write-only: replaceable, removable, never readable, and
invisible to administrators.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant