Back to feed

sledtools/pika branch #120

pika-orch-incus-cleanup-23

Split pikaci execution placement from runtime backend

Target branch: master

Merge Commit: 115eeae86052cad4f16c01b783300336efc595d7

branch: merged tutorial: ready ci: failed
Open CI Details

Continuous Integration

CI: failed

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #147 failed

1 passed 9 failed

head a13d31c4943d14f0e7dca2438c43e99a6b509f82 · queued 2026-03-26 21:58:21 · 10 lane(s)

queued 9s · ran 2m 18s

check-pika-rust · failed check-pika-followup · failed check-notifications · failed check-agent-contracts · failed check-rmp · failed check-pikachat · failed check-pikachat-typescript · failed check-apple-host-sanity · success check-pikachat-openclaw-e2e · failed check-fixture · failed

Summary

This branch decouples the concepts of job placement (where a CI job runs: locally or on a remote SSH host) from job runtime (how the job is isolated: bare host process, Incus container, or Tart VM). Previously these two axes were conflated into a single RunnerKind enum. The refactor introduces JobPlacementKind and JobRuntimeKind as independent enums, combines them into a JobExecutionConfig struct carried on every JobSpec, moves the hard-coded per-target path-filter lists out of Rust source and into an external TOML manifest (ci/forge-lanes.toml), and extracts the full staged-Linux target/lane catalog from the library crate into a binary-only catalog module. An invariants.toml file and companion check script enforce that the TOML manifest stays in sync with the Rust catalog at CI time.

Tutorial Steps

Introduce `JobPlacementKind` and `JobRuntimeKind` enums

Intent: Replace the single `RunnerKind` enum with two orthogonal dimensions: *where* a job executes (`JobPlacementKind`: `Local` or `RemoteSsh`) and *how* it is isolated (`JobRuntimeKind`: `HostProcess`, `Incus`, or `Tart`). This lets every combination be expressed explicitly instead of overloading a single discriminant.

Affected files: crates/pikaci/src/model.rs

Evidence
@@ -274,10 +275,21 @@ pub fn run_job_on_runner(job: &JobSpec, ctx: &HostContext) -> anyhow::Result<JobOutcome> {
-    match job.runner_kind() {
-        RunnerKind::HostLocal => run_host_local_job(job, ctx),
-        RunnerKind::RemoteLinuxVm => run_remote_linux_vm_job(job, ctx),
-        RunnerKind::TartLocal => run_tart_job(job, ctx),
+    match job.runtime_kind() {
+        JobRuntimeKind::HostProcess => match job.placement_kind() {
@@ -5,7 +5,8 @@ use pikaci::{load_logs, load_run_bundle, load_run_record, LogKind, Logs, RunBund
-    JobRecord, PreparedOutputsRecord, RemoteLinuxVmExecutionRecord, RunLifecycleEvent, RunStatus,
+    JobPlacementKind, JobRecord, JobRuntimeKind, PreparedOutputsRecord,
+    RemoteLinuxVmExecutionRecord, RunLifecycleEvent, RunStatus,

The old RunnerKind enum (HostLocal, RemoteLinuxVm, TartLocal) encoded both where a job runs and what virtualisation backend it uses in a single value. This branch splits it into two independent enums:

pub enum JobPlacementKind { Local, RemoteSsh }
pub enum JobRuntimeKind  { HostProcess, Incus, Tart }

These are paired inside JobExecutionConfig, which exposes three canonical constants (HOST_LOCAL, REMOTE_SSH_INCUS, LOCAL_TART) for the combinations that are currently supported. The run_job_on_runner dispatcher now performs a two-level match — first on runtime_kind(), then on placement_kind() — and returns explicit bail!() errors for combinations that are not yet implemented (e.g. remote-SSH Tart, local Incus). This makes it straightforward to add new placement×runtime pairs later without touching existing arms.

Add `JobExecutionConfig` to `JobSpec` and wire up accessor helpers

Intent: Embed the new execution config on every job so that the executor can query placement and runtime independently, and add convenience accessors (`placement_kind()`, `runtime_kind()`, `mount_host_rust_toolchain()`) on `JobSpec`.

Affected files: crates/pikaci/src/model.rs, crates/pikaci/src/executor.rs, crates/pikaci/src/executor/incus.rs

Evidence
@@ -2503,6 +2518,34 @@ mod tests {
+    fn remote_incus_job_base() -> JobSpec {
+        JobSpec {
+            execution: JobExecutionConfig::REMOTE_SSH_INCUS,
+            ..
@@ -1498,7 +1513,7 @@ fn tart_tagged_share(path: &Path, read_only: bool, tag: &str) -> String {
-    job.id == "tart-env-probe" || job.id.starts_with("tart-desktop")
+    job.mount_host_rust_toolchain()

JobSpec gains three new fields:

FieldTypePurpose
executionJobExecutionConfigCarries JobPlacementKind + JobRuntimeKind
host_setup_commandOption<&'static str>Optional pre-flight command on the host (used by Tart jobs)
mount_host_rust_toolchainboolWhether to bind-mount the host Rust toolchain into the guest

The old magic string comparison job.id == "tart-env-probe" for deciding whether to mount the Rust toolchain is replaced by a clean job.mount_host_rust_toolchain() accessor at crates/pikaci/src/executor.rs:1516. Every test helper (remote_incus_job_base(), host_local_job_base(), tart_job_base()) is updated to supply the correct JobExecutionConfig constant so that tests compile and correctly exercise each combination.

Replace `staged_linux_rust_lane` with `staged_linux_command`

Intent: Decouple the execute-time command configuration from the lane identity. A `JobSpec` no longer carries a lane enum variant; it carries a `StagedLinuxCommandConfig` that holds only the information needed at execution time (snapshot profile, wrapper command, payload specs, workspace output system).

Affected files: crates/pikaci/src/model.rs, crates/pikaci/src/executor.rs, crates/pikaci/src/executor/incus.rs

Evidence
@@ -951,9 +963,11 @@ fn run_remote_linux_vm_job(job: &JobSpec, ctx: &HostContext) -> anyhow::Result<JobOutcome> {
-    let lane = job
-        .staged_linux_rust_lane()
-        .ok_or_else(|| anyhow!("remote Linux VM execute requires a staged Linux Rust lane"))?;
+    let (guest_command, _) = compiled_guest_command(job);
+    let workspace_output_system = job
+        .staged_linux_command()
+        .map(|command| command.workspace_output_system)
@@ -2377,8 +2392,8 @@ fn cleanup_remote_linux_vm_runtime(
-    if let Some(lane) = job.staged_linux_rust_lane() {
-        return (lane.execute_wrapper_command().to_string(), false);
+    if let Some(command) = job.staged_linux_command() {
+        return (command.execute_wrapper_command.to_string(), false);

Previously JobSpec stored an Option<StagedLinuxRustLane> — an enum variant that encoded both which lane runs and how to run it. The branch replaces this with Option<StagedLinuxCommandConfig>, a plain data struct:

pub struct StagedLinuxCommandConfig {
    pub snapshot_profile: StagedLinuxSnapshotProfile,
    pub prepare_node_prefix: &'static str,
    pub prepare_description: &'static str,
    pub payload_specs: [StagedLinuxRustTargetPayloadSpec; 2],
    pub execute_wrapper_command: &'static str,
    pub workspace_output_system: &'static str,
}

This means the executor module (crates/pikaci/src/executor.rs) no longer needs to know anything about lane identity — it only consults the command config for the wrapper binary path, snapshot profile, and payload specs. The compiled_guest_command function, the Incus device configuration, and the remote snapshot directory logic all switch from .staged_linux_rust_lane() to .staged_linux_command().

Extract the staged-Linux target and lane catalog into `catalog.rs`

Intent: Move the full enumeration of build targets and CI lanes out of the library crate and into a binary-only module, keeping the library focused on execution primitives.

Affected files: crates/pikaci/src/catalog.rs, crates/pikaci/src/main.rs, crates/pikaci/src/lib.rs

Evidence
@@ -0,0 +1,424 @@
+use serde::Serialize;
+
+use pikaci::{
+    StagedLinuxCommandConfig, StagedLinuxRustPayloadRole, StagedLinuxRustTargetPayloadSpec,
+    StagedLinuxSnapshotProfile,
+};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) enum PikaStagedLinuxTarget {
@@ -1,11 +1,15 @@
+mod catalog;
+mod forge_manifest;

The new crates/pikaci/src/catalog.rs (424 lines) defines:

  • PikaStagedLinuxTarget — the 9 build-target variants (e.g. PreMergePikaRust, PreMergePikachatRust).
  • PikaStagedLinuxLane — the ~27 individual lane variants, each mapping to a target and a command config.
  • PikaStagedLinuxTargetConfig — per-target metadata (Nix installable paths, snapshot profile, shadow recipe).
  • PikaStagedLinuxTargetInfoJson — a JSON-serialisable projection used by the --json CLI flag.

Each lane's command_config() method constructs a StagedLinuxCommandConfig by combining the target's shared settings with a lane-specific execute_wrapper_command path. This keeps the full catalog close to the binary entry point (main.rs) while the library crate (lib.rs) re-exports only the execution-time types.

The lib.rs public API now exports JobExecutionConfig, JobPlacementKind, JobRuntimeKind, StagedLinuxCommandConfig, StagedLinuxRustPayloadRole, StagedLinuxRustTargetPayloadSpec, and StagedLinuxSnapshotProfile — but no longer exports StagedLinuxRustLane or StagedLinuxRustTarget.

Move path filters from Rust source to `ci/forge-lanes.toml`

Intent: Eliminate hundreds of lines of hard-coded per-target file-glob arrays from `main.rs` by loading them at runtime from an external TOML manifest. This makes path filters editable without recompiling the binary and keeps them co-located with the CI workflow definitions.

Affected files: crates/pikaci/src/forge_manifest.rs, crates/pikaci/src/main.rs, crates/pikaci/Cargo.toml, Cargo.lock

Evidence
@@ -0,0 +1,100 @@
+use std::collections::HashMap;
+use std::path::Path;
+
+use anyhow::{Context, anyhow};
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize)]
+struct ManifestFile {
+    version: u32,
+    #[serde(default)]
+    branch: LaneGroup,
@@ -400,23 +452,28 @@ fn rerun_target_with_output(
-fn staged_linux_target(target_id: &str) -> anyhow::Result<StagedLinuxRustTarget> {
-    StagedLinuxRustTarget::from_target_id(target_id)
+fn staged_linux_target(target_id: &str) -> anyhow::Result<PikaStagedLinuxTarget> {
+    PikaStagedLinuxTarget::from_target_id(target_id)
@@ -12,5 +12,6 @@ serde_json = { workspace = true }
+toml = { workspace = true }

A new crates/pikaci/src/forge_manifest.rs module reads ci/forge-lanes.toml (located relative to CARGO_MANIFEST_DIR) and parses it with the toml crate (added as a workspace dependency).

The TOML structure is:

version = 1

[[branch.lanes]]
staged_linux_target = "pre-merge-pika-rust"
paths = ["rust/**", "Cargo.lock", ...]

The function branch_lane_paths_for_staged_target(target_id) returns Option<Vec<String>> — the path glob list for a given target, or None if the target isn't in the manifest.

In main.rs, the staged_linux_target_spec() helper drops its filters: &'static [&'static str] parameter. It now calls branch_lane_paths_for_staged_target and fails with a clear error if the manifest entry is missing. This removes approximately 200 lines of inline filter arrays from the match arms in target_spec(). Non-staged targets (e.g. tart-beachhead, android-sdk-probe) keep their inline filter lists via a static_filters() conversion helper.

Two tests in forge_manifest.rs verify that the manifest loads correctly for pre-merge-pika-rust and returns None for unknown targets.

Add `TargetSpec` filter type migration and job base factories

Intent: Unify how target specs express their path filters (switching from `&'static [&'static str]` to `Vec<String>`) and provide canonical job base constructors for each execution config.

Affected files: crates/pikaci/src/main.rs

Evidence
@@ -13,10 +17,58 @@ struct TargetSpec {
-    filters: &'static [&'static str],
+    filters: Vec<String>,
@@ +26,+42 @@
+fn remote_incus_job_base() -> JobSpec {
+fn host_local_job_base() -> JobSpec {
+fn tart_job_base() -> JobSpec {

The TargetSpec.filters field changes from a borrowed static slice to an owned Vec<String>, since the TOML-loaded paths are heap-allocated. A static_filters() helper converts the remaining &'static [&'static str] literals for non-staged targets.

Three factory functions in main.rs construct canonical base JobSpec values:

  • remote_incus_job_base()REMOTE_SSH_INCUS placement+runtime
  • host_local_job_base()HOST_LOCAL placement+runtime
  • tart_job_base()LOCAL_TART with a default host_setup_command for Xcode developer directory setup

All job definitions in the match arms now use struct update syntax (..remote_incus_job_base()) instead of repeating boilerplate fields. The Tart TART_HOST_SETUP_COMMAND constant is extracted at the top of the file so it can be shared across all Tart job specs.

Update test fixtures in `pika-git` and executor tests

Intent: Ensure all test code compiles against the new model by adding the `placement` and `runtime` fields to job record fixtures and switching test job constructors to use the new base factories.

Affected files: crates/pika-git/src/pikaci_store.rs, crates/pikaci/src/executor.rs, crates/pikaci/src/executor/incus.rs

Evidence
@@ -65,6 +66,8 @@ impl TestPikaciJobFixture {
+            placement: Some(JobPlacementKind::RemoteSsh),
+            runtime: Some(JobRuntimeKind::Incus),
@@ -2503,6 +2518,34 @@ mod tests {
+    fn remote_incus_job_base() -> JobSpec {
+    fn host_local_job_base() -> JobSpec {
@@ -644,10 +644,13 @@ pub(super) fn build_snapshot_mount_plan_for_test(
+            execution: super::super::JobExecutionConfig::REMOTE_SSH_INCUS,
+            host_setup_command: None,
+            mount_host_rust_toolchain: false,

TestPikaciJobFixture in crates/pika-git/src/pikaci_store.rs now sets placement: Some(JobPlacementKind::RemoteSsh) and runtime: Some(JobRuntimeKind::Incus) on every generated JobRecord. These Option-wrapped fields allow the serialised record format to remain backwards-compatible with older run bundles that lack the new fields.

In the executor test module, remote_incus_job_base() and host_local_job_base() mirror the main binary's factories. Every inline JobSpec literal in the test suite now uses ..remote_incus_job_base() or ..host_local_job_base() to fill in the execution config, replacing the old staged_linux_rust_lane: None field.

The Incus test helper build_snapshot_mount_plan_for_test adds the three new fields (execution, host_setup_command, mount_host_rust_toolchain) directly.

Add invariants check for catalog ↔ manifest consistency

Intent: Prevent the Rust catalog and the TOML manifest from drifting apart by adding a machine-enforced invariant that every staged target in the catalog has a corresponding entry in `ci/forge-lanes.toml`.

Affected files: invariants/invariants.toml, scripts/check_invariants.py

Evidence
@@ (new file) invariants/invariants.toml
@@ (new file) scripts/check_invariants.py

Two new files enforce structural consistency:

  1. invariants/invariants.toml — declares the set of staged-Linux target IDs and the path to the forge-lanes manifest.
  2. scripts/check_invariants.py — a Python script (runnable in CI) that:
    • Parses the invariants file to get the expected target list.
    • Parses ci/forge-lanes.toml to get the actual staged_linux_target entries.
    • Asserts that every expected target is present and that the manifest contains no unexpected targets.

This closes the loop: the Rust catalog.rs defines what targets exist, the TOML manifest provides their path filters, and the invariants script ensures neither side can add or remove a target without updating the other.

Improve remote Linux VM job logging

Intent: Include the actual guest command in the start-of-job log line so operators can identify what a remote VM job will run without cross-referencing the lane name.

Affected files: crates/pikaci/src/executor.rs

Evidence
@@ -985,11 +999,12 @@ fn run_remote_linux_vm_job(job: &JobSpec, ctx: &HostContext) -> anyhow::Result<J
-                "[pikaci] starting remote Linux VM backend `{}` for staged lane `{}` on {} at {}",
+                "[pikaci] starting remote Linux VM backend `{}` for `{}` on {} at {}: {}",
                 remote_linux_vm_backend_label(backend),
-                lane.workspace_output_system(),
+                workspace_output_system,
                 shared.remote_host,
-                Utc::now().to_rfc3339()
+                Utc::now().to_rfc3339(),
+                guest_command

The log line emitted when a remote Linux VM job starts now includes the resolved guest command string as a trailing field. Previously it logged only the workspace_output_system (derived from the lane); now the compiled_guest_command() result is called at the top of run_remote_linux_vm_job and appended to the message. The label source also changes from lane.workspace_output_system() to workspace_output_system, a local variable derived from the staged_linux_command() config (or the literal "direct_guest_command" for non-staged jobs). This gives operators immediate visibility into what binary path will be invoked inside the VM.

Diff