Launcher and process model#

Application samples own their DeviceIOHub and worker processes. Model services may instead be declared with launch_mode="reuse" and started separately through the shared model-servers stack.

An application sample has at least these two sub-projects:

Sub-project

Role

Dependencies

<sample>/

Orchestrator — declares owned and reused processes in code

xr-ai-launcher, xr-ai-logging, and sample-specific orchestration dependencies

<sample>/worker/

Agent worker — connects to hub via IPC, runs agent logic

xr-ai-hub-client, numpy, etc.

Samples may add capability or eval sub-projects. model-servers is a dedicated orchestrator and has no worker sub-project.

Configuration convention — the YAML configuration path for each process is declared explicitly in the orchestrator’s PROCESSES list via the config= field of Process. The launcher passes it as --config <path> to the subprocess. Application and service configuration normally lives in the sample’s yaml/ directory. A capability sub-project may keep its configuration beside that project, such as xr-render-demo/scene/scene_service.yaml. Omit config= for processes that use their own internal defaults.

Samples that support interchangeable local and hosted models may set models_config in the worker YAML. The worker SDK’s load_models_config() accepts omitted deployment metadata and defaults it to external ownership. Consumer samples declare only application processes and let workers connect to the configured endpoints; their launchers do not read model profiles.

Launcher profile loaders require the wrapped JSON form with adapter, endpoint, and deployment objects for each model. load_model_deployment() reads the profile selected by the worker YAML, while load_deployment_profile() reads a profile path directly. Both use only the standard library and expose deployment metadata and credential requirements. The shared model-servers sample uses the direct loader and managed entries to select servers. Legacy explicit reused and external deployment entries remain accepted. Refer to model profile formats for the worker and launcher requirements.

The orchestrator declares the process sequence in code:

_BASE = Path(__file__).resolve().parent   # sample root

PROCESSES = [
    Process("hub",    "../../services/device-io-hub", "device_io_hub",
            config="yaml/device_io_hub.yaml"),
    Process("worker", "worker",               "my_agent_worker",
            config="yaml/my_agent_worker.yaml"),
    # Optional shared components — add as needed:
    # Process("cloudxr", "../../services/cloudxr-runtime", "cloudxr_runtime",
    #         config="yaml/cloudxr_runtime.yaml"),
]

def run() -> None:
    run_stack(PROCESSES, _BASE)

Rules#

  • Spawned stack items start in declaration order — non-reuse members of a Parallel item start concurrently, and the launcher waits for each spawned Process or Parallel member to create its --ready-file before starting the next item. Declare items in dependency order (hub before workers and application processes after the services they call).

  • Every spawned process accepts --ready-file <path> and must Path(path).touch() when it is fully initialized and ready to serve requests.

  • Native voice workers pass the ready file to VoiceAgent; its private media session touches it only after the input transport’s hub IPC receive loop has started.

  • device_io_hub always runs as its own process — never embedded in-process.

  • The worker never imports anything from device_io_hub or xr_ai_launcher.

  • Process management lives in utils/xr-ai-launcher/, not inside any process it manages.

  • run_stack is fail-fast while monitoring: if any spawned process exits, the rest are terminated.

Serial and parallel items#

The stack is declared as a sequence of Process or Parallel items:

  • Process — when not configured with launch_mode="reuse", started alone; the launcher waits for it to signal ready before moving on.

  • Parallel([p1, p2, ...]) — all non-reuse processes in the group are started at once; the launcher waits for every spawned member to signal ready before the next item in the sequence begins. If any spawned member exits before signaling ready, the launcher shuts everything down, just as it would for a serial process.

PROCESSES = [
    Process("vlm", "../../services/vlm-server", "vlm_server",
            launch_mode="reuse"),
    Process(
        "embedding", "../../services/embedding-server", "embedding_server",
        launch_mode="reuse",
    ),
    Process("hub", "../../services/device-io-hub", "device_io_hub",
            config="yaml/device_io_hub.yaml"),
    Parallel([
        Process("video-memory", "../../services/video-memory-service",
                "video_memory_service", config="yaml/video_memory_service.yaml"),
        Process("rag", "../../services/rag-service", "rag_service",
                config="yaml/rag_service.yaml"),
    ]),
    Process("worker", "worker", "my_agent_worker"),
]

How run_stack works#

For each spawned process (an entry not configured with launch_mode="reuse"), the launcher:

  1. Resolves the project directory and YAML configuration from the sample root (base — all relative paths in Process.project and Process.config are resolved against it).

  2. Spawns uv run --project <dir> <command> --config <yaml> --ready-file <f> in a new process group, so the whole group (uv plus its children) can be torn down together rather than leaving orphans.

  3. Waits for the process to create (the ready file), recording a DEBUG progress line every five seconds. It is visible with XR_AI_VERBOSE and in the per-run log.

  4. Once all processes are ready, monitors them: any exit triggers a graceful shutdown of the rest (SIGTERM, escalating to SIGKILL after a timeout).

Each spawned process is responsible for creating its own ready file at the moment it is fully initialized and able to serve requests — after model warm-up, after the IPC socket connects, after the HTTP server starts listening, etc.

Pass exit_after_ready=True to run_stack to return immediately once everything is ready instead of monitoring — useful for launchers whose processes are all launch_mode="persist" and are designed to outlive the orchestrator (e.g. model-servers). For a persistent process, this also tells a bootstrap that reused an already-running service that it may exit after signaling readiness. Without exit_after_ready=True, the bootstrap remains alive so the launcher can continue monitoring it.

The --ready-file protocol#

The launcher injects --ready-file <path> into every spawned command. The process must Path(path).touch() the moment it is fully initialized and able to serve requests. The launcher blocks on the file’s existence; if the process exits before creating it, startup is aborted and the whole stack is torn down. This makes readiness explicit and process-defined: a model server signals ready after weights load, an HTTP server after it starts listening, a worker after its IPC receive loop is active.

launch_mode: own, persist, reuse#

Process.launch_mode controls spawn and shutdown behaviour:

  • "own" (default) — the launcher spawns this process and kills it on shutdown.

  • "persist" — the launcher spawns this process but leaves it running on shutdown. Use for heavy model servers that need to survive stack restarts (e.g. vLLM containers). Cleanup is the caller’s responsibility. The optional port field is metadata; model-servers uses it to select cleanup targets, while generic run_stack does not inspect it.

  • "reuse" — the launcher does not spawn this process; it is assumed to be already running (e.g. started by model-servers). The entry in the process list documents the dependency; the launcher skips it entirely and does not kill it on shutdown.

On a clean ready-exit, persist and reuse processes are left running. On an abort during startup (Ctrl-C, or a process exiting before it signals ready) the launcher tears down everything, including persist processes, so no half-started service is left behind.

Adding a new managed process#

There is no per-process launcher module to write — the launcher spawns any uv sub-project generically. To add a new process to a stack:

  1. Make the sub-project’s entry-point command accept --ready-file <path> (touch it once ready) and, if it takes configuration, --config <path>.

  2. Add a Process (or Parallel) entry to the orchestrator’s PROCESSES list, in dependency order, pointing at the sub-project directory and its entry-point command — exactly like the hub and worker entries above.

Shared utils/ packages#

The orchestrator’s process management and several cross-cutting concerns live in single-purpose packages under utils/. Each is small and narrowly scoped, and most are deliberately dependency-light so they can be added to any sub-project without dragging in a heavy dependency chain. Their public names, signatures, types, defaults, fields, and method behavior are generated in the Python API reference.

xr-ai-launcher — process management for the xr-ai stack: the Process, Parallel, and run_stack API described above, plus helpers for CloudXR environment setup, credential loading, and GPU detection. Intentionally stdlib-only so it can be added to any sample without pulling in the dependency chain of the processes it manages.

xr-ai-logging — shared loguru setup for the monorepo. Every process calls setup_logging() once at startup to get a unified logging stack: a stderr sink (level controlled by XR_AI_VERBOSE), a DEBUG file sink under /tmp/log_<namespace>_<timestamp>/, and a stdlib bridge that routes records emitted via logging.getLogger(...) into loguru — so stdlib-only packages (xr-ai-launcher) and the agent SDK end up in the same sinks. The orchestrator stamps namespace, timestamp, and root env vars so all spawned subprocesses write into the same per-run folder.

xr-ai-vad — shared Silero-VAD utterance detector for agent workers. It consumes int16 LE PCM audio and emits int16 PCM utterance bytes via an async callback when speech ends, so workers get a single, consistent voice-activity boundary without each re-implementing VAD.

xr-ai-voicegate — the speech-only opt-in gate shared by agent workers. It owns the magic-phrase, follow-up, and STOP ladder, the lazy listening chime, and the participant-joined greeting hook. Workers feed STT transcripts via feed and register handlers for the events it emits (query, stop, phrase-only, drop, participant-joined).

xr-ai-vllm — pluggable vLLM backend for inference services. Each vLLM-backed service can host vllm via pip (the pip-installed vllm CLI in the wrapper’s venv, the default) or docker (the image selected by vllm_image), chosen per-server via vllm_backend: pip|docker in the service YAML. Both paths honor identical configuration keys; only the runtime hosting vllm differs. Stdlib-only by contract, so the docker path stays light even when pip vllm is not installed.