Skip to content

Launchers

A launcher provider maps submit, status, and cancel (and, for providers that declare it, capacity and logs) onto a platform’s real execution mechanism. This page covers the credential-free local-runs launcher in depth, then the two platform launchers, Weights & Biases Launch and Dagster, that share the same contract described in contracts and conformance.

plugins/local-runs is the zero-credential mlops.launcher provider: its provides block maps submit/status/cancel plus the capacity and logs capability groups onto the first-party mlops.local.* tools. It depends on mlops-core and declares one page type, mlops/template.

A binding page carries two pieces of metadata:

  • metadata.fs.path, the workspace directory (the same field the filesystem tracker watches).
  • metadata.mlops.launcher {provider: 'local-runs', template: <page ref>, capacity?: {slots? | argv?}}, pointing at a separate template page.

The template page carries metadata.mlops.template {mode: 'bash' | 'docker', argv? (bash mode), image/cmd/mounts/gpus/memoryMb/cpus/network/env (docker mode)}. Placeholders {jobId}, {runDir}, {gitRef}, and {config.<key>} render per launch instance, only inside argv-style arrays, and an unknown placeholder fails the render closed rather than launching with a literal {typo} in the command line.

mlops.local.submit {page?, config?, gitRef?, hypothesis?} (admin-only, alwaysGate, idempotency claim mlops.submit:<callId>; page defaults to the invoking page) renders the bound template. In bash mode it spawns the rendered argv through the PTY host in a terminal node on the binding page, so the run’s live progress is literally its scrollback. Spawning requires SUBSPACE_OS_EXEC=1 on the server. Bash-lane submits always gate per call and never auto-launch, regardless of any standing approval.

Docker mode launches a detached container through the containers.* gated core tool family, a thin wrapper over the Docker CLI via execFile (argv only, never a shell):

Tool Behavior
containers.run {image, cmd?, mounts?, gpus?, memoryMb?, cpus?, network?, env?, name?} Detached start, returns the container id. Admin-only, alwaysGate, claim containers.run:<callId>.
containers.status {id} Ungated read.
containers.logs {id, tail?} Ungated read.
containers.stop {id} Admin-only, needsApproval (policy-relaxable).

Isolation defaults lock the container down unless the template explicitly opens it up: --network none unless a network is named, mounts are read-only unless readonly: false, env values are literal strings only (broker-acquired secret material never enters a container’s environment), and gpus/memoryMb/cpus cap resource use. Opt in to real Docker with SUBSPACE_CONTAINERS=1, or point SUBSPACE_DOCKER_BIN at a fixture binary for testing.

Both lanes pre-create the mlops/run page skeleton before the process or container starts; the filesystem tracker then adopts that same page by (source page, job id) on its next sweep, so a run never gets a duplicate page and its status never gets demoted from a terminal state by a stale materialize. The job id derives from the durable call id, so a crash-replay resumes the same job rather than launching a second one.

The launch template is the consent object for the Docker lane. mlops.approveTemplate {page} (admin) pins the template’s current version hash, captured through page versioning and recorded in ops.plugin_state under local-runs. A subsequent Docker-lane submit whose template still hash-matches the approved version auto-launches: it lifts the standing alwaysGate through the tool’s standingApproval seam, checked as part of the ordinary gating decision. This is deterministic and human-granted, never a model deciding to relax its own gate: any edit to the template page changes its hash and immediately revokes the standing approval, so the next submit falls back to a normal approval card. mlops.revokeTemplateApproval {page} revokes explicitly, and mlops.templateApproval {page} reports {approved, approvedHash, currentHash, by, at} so you can see at a glance whether a binding is currently auto-launching and against which pinned version.

A bash run’s completion rides the PTY host’s notifyExit outbox event; a consumer finalizes the run page from it. A Docker run has no such hook, so a watcher polls every SUBSPACE_MLOPS_SWEEP_MS (default 20 seconds), scanning running mlops/run pages that carry a containerId. Because all of this state lives on pages rather than in memory, a server restart re-arms the watcher automatically with no separate recovery step. Finalizing a run flips its status (finished or failed by exit code, killed on cancel), stamps finishedAt, emits mlops-run-ingested, and resumes any workflow durably waiting on an mlops-run:<pageId> hook.

mlops.local.status syncs a run page from the live container or PTY state and returns contract LaunchStatus JSON; mlops.local.cancel (admin, gated) issues a docker stop or kills the PTY child; mlops.local.logs tails docker logs or PTY scrollback (ungated). mlops.local.capacity {page?} (ungated) reports the binding’s manual capacity.slots if set, otherwise runs its probe argv: a bare integer output means that many slots, a per-line GPU utilization reading treats each line under 20% utilization as one free slot, and the default probe is nvidia-smi. A failing probe degrades to {slots: 0} rather than erroring the caller.

The mlops.launch-run registered workflow wraps mlops.local.submit in the same gate/claim/tree-rendering envelope as mlops.ingest-runs, taking workflowArgs {page?, config?, gitRef?, hypothesis?}; the agents/mlops-launch definition page rides it for slash-invoke (/mlops-launch) on a binding page.

The W&B tracker plugin (see trackers) is also a full launcher: submit, status, cancel, and capacity map onto W&B Launch queues, with capacity read from live queue depth rather than a local probe. Native sweep submission goes through the same launcher, so a sweep started from Subspace lands as a real W&B sweep, not the Cartesian polyfill. External submits are approval-gated and claimed per call id like every other launcher.

The Dagster plugin’s launcher side speaks GraphQL directly: launchRun to submit, terminateRun to cancel, and status polling against the same API. Capacity comes from a live read of the coordinator’s active and queued runs against the binding’s maxConcurrent, so the scheduler sees real headroom rather than a static number.

Every launcher’s capacity group exists for one consumer above the MLOps layer: the research scheduler drains a goal’s backlog against exactly the capacity a bound launcher reports right now, whether that is a nvidia-smi probe on a local box, a W&B queue depth, or Dagster’s run-queue count. Because capacity is a live contract call rather than a config constant, the scheduler never over-submits into a launcher that is actually full.