Back to feed

sledtools/pika branch #85

pika-cloud-next

Hard cut pikaci result contract

Target branch: master

Merge Commit: faf51f5bf2dcf8b67d1a4a853598be813fc00250

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

Continuous Integration

CI: success

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

Open CI Details

Latest run #110 success

10 passed

head 7871a3d43c80e3cffc605fdd9d5347e03eb0c446 · queued 2026-03-26 00:54:54 · 10 lane(s)

queued 11s · ran 1m 56s

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

Summary

This branch hardens the result contract between pikaci guest VMs and the host executor by replacing the ad-hoc GuestResult struct (which used loose string statuses like "passed"/"failed") with a shared, versioned RuntimeTerminalResult type defined in the pika-cloud crate. The migration introduces a schema_version field, renames the success status from "passed" to "completed", adds encode/decode/factory helper functions in pika-cloud, updates all three execution backends (host-local, remote Linux VM, Tart) to produce and consume the canonical type, and aligns the shell-script result writers in both Nix guest images and the Rust-rendered Tart guest script. Comprehensive round-trip and rejection tests ensure the new contract is enforced and that legacy "passed" payloads are explicitly rejected.

Tutorial Steps

Add helper functions and factory to pika-cloud lifecycle module

Intent: Provide a single source of truth for constructing, encoding, and decoding RuntimeTerminalResult values so that every producer and consumer uses the same contract instead of hand-rolling JSON serialization with ad-hoc structs.

Affected files: crates/pika-cloud/src/lifecycle.rs, crates/pika-cloud/src/lib.rs

Evidence
@@ -94,6 +94,41 @@ pub struct LifecycleTerminalResult {
+impl LifecycleTerminalStatus {
+    pub fn from_exit_code(exit_code: i32) -> Self {
+        if exit_code == 0 {
+            Self::Completed
+        } else {
+            Self::Failed
+        }
+    }
+}
+
+pub fn runtime_terminal_result_for_exit_code(
+    exit_code: i32,
+    finished_at: impl Into<String>,
+    message: impl Into<String>,
+) -> RuntimeTerminalResult {
+pub fn encode_runtime_terminal_result_pretty(
+    result: &RuntimeTerminalResult,
+) -> serde_json::Result<Vec<u8>> {
+pub fn decode_runtime_terminal_result(bytes: &[u8]) -> serde_json::Result<RuntimeTerminalResult> {
@@ -11,7 +11,8 @@ pub use lifecycle::{
+    RuntimeStatusSnapshot, RuntimeTerminalResult, decode_runtime_terminal_result,
+    encode_runtime_terminal_result_pretty, runtime_terminal_result_for_exit_code,

Three new public helpers are added to crates/pika-cloud/src/lifecycle.rs:

  1. runtime_terminal_result_for_exit_code – Factory that builds a RuntimeTerminalResult from an exit code, timestamp, and message. It maps exit code 0 → Completed, anything else → Failed, and always stamps schema_version: LIFECYCLE_SCHEMA_VERSION.
  2. encode_runtime_terminal_result_pretty – Thin wrapper around serde_json::to_vec_pretty for consistent pretty-printed encoding.
  3. decode_runtime_terminal_result – Thin wrapper around serde_json::from_slice that deserializes bytes into the canonical struct.

LifecycleTerminalStatus also gains a from_exit_code method, which the factory delegates to.

All three helpers, plus the RuntimeTerminalResult type itself, are re-exported from crates/pika-cloud/src/lib.rs so downstream crates can import them from the crate root.

Remove ad-hoc GuestResult struct and migrate executor backends

Intent: Eliminate the loosely-typed GuestResult struct that used raw strings for status and replace every usage in the three execution backends (host-local, remote Linux VM, Tart) with the shared RuntimeTerminalResult type and its helpers.

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

Evidence
@@ -59,14 +60,6 @@ fn resolved_host_local_openclaw_dir(ctx: &HostContext) -> Option<PathBuf> {
-#[derive(Clone, Debug, Deserialize, Serialize)]
-struct GuestResult {
-    status: String,
-    exit_code: i32,
-    finished_at: String,
-    message: Option<String>,
-}
@@ -421,19 +414,12 @@ fn run_host_local_job
-    let result = GuestResult {
-        status: match status {
-            RunStatus::Passed => "passed".to_string(),
-            _ => "failed".to_string(),
-        },
+    let result =
+        runtime_terminal_result_for_exit_code(exit_code, Utc::now().to_rfc3339(), message.clone());
@@ -1022,17 +1008,11 @@ fn run_remote_linux_vm_job
-        let guest_result = load_guest_result(&ctx.job_dir.join("artifacts/result.json"))?;
-        let status = match guest_result.status.as_str() {
-            "passed" => RunStatus::Passed,
-            _ => RunStatus::Failed,
-        };
+        let guest_result = load_guest_terminal_result(&ctx.job_dir.join("artifacts/result.json"))?;
         Ok(JobOutcome {
-            status,
-            exit_code: Some(guest_result.exit_code),
+            status: run_status_from_terminal_result(&guest_result),
+            exit_code: guest_result.exit_code,
@@ -1744,9 +1719,16 @@ fn run_command_to_log
-fn load_guest_result(path: &Path) -> anyhow::Result<GuestResult> {
+fn load_guest_terminal_result(path: &Path) -> anyhow::Result<RuntimeTerminalResult> {
+fn run_status_from_terminal_result(result: &RuntimeTerminalResult) -> RunStatus {
+    match result.status {
+        RuntimeResultStatus::Completed => RunStatus::Passed,
+        RuntimeResultStatus::Failed => RunStatus::Failed,
+    }

The private GuestResult struct is deleted entirely from crates/pikaci/src/executor.rs. In its place:

  • load_guest_terminal_result replaces load_guest_result, calling decode_runtime_terminal_result to deserialize into the shared type.
  • run_status_from_terminal_result maps RuntimeResultStatus::CompletedRunStatus::Passed and FailedFailed, replacing scattered match guest_result.status.as_str() blocks.

All three backends are updated:

BackendChange
host-localConstructs result via runtime_terminal_result_for_exit_code and writes with encode_runtime_terminal_result_pretty.
remote Linux VMReads result with load_guest_terminal_result, maps status with run_status_from_terminal_result.
TartSame read/map pattern as remote Linux VM.

Note that exit_code on RuntimeTerminalResult is Option<i32>, so the three sites that previously wrapped exit codes in Some(...) now pass the field through directly.

Update Tart guest script to emit the new contract vocabulary

Intent: Align the shell script rendered by the Rust Tart backend so the JSON it writes inside the guest VM uses "completed" instead of "passed" and includes the schema_version field.

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

Evidence
@@ -1657,7 +1631,7 @@ set +e
-status="passed"
+status="completed"
@@ -1665,6 +1639,7 @@ fi
 cat > "{artifacts_mount}/result.json" <<EOF
 {
+  "schema_version": 1,

The render_tart_guest_script function in the Rust executor produces a shell heredoc that writes result.json inside the macOS Tart VM. Two changes bring it in line with the new contract:

  1. The success status literal changes from "passed" to "completed".
  2. A "schema_version": 1 field is added to the JSON object.

These ensure that when the host-side executor later calls decode_runtime_terminal_result, the payload deserializes cleanly into RuntimeTerminalResult without hitting the "unknown variant" rejection path.

Update Incus (Linux) guest image result writer

Intent: Align the Python result-writing helper in the Incus NixOS guest image so it emits "completed" instead of "passed", matching the new enum variants that the host-side decoder expects.

Affected files: nix/incus/pikaci-image.nix

Evidence
@@ -58,8 +58,7 @@ let
     def write_result(state_dir: pathlib.Path, exit_code: int, message: str) -> None:
-        status = "passed" if exit_code == 0 else "failed"
-        write_status(state_dir, status, message)
+        status = "completed" if exit_code == 0 else "failed"

Inside nix/incus/pikaci-image.nix, the Python write_result helper determines the status string written to result.json. The ternary is updated from "passed" to "completed". The redundant write_status call is also removed, leaving only the write_json call that produces the result file.

This is the counterpart change for the Incus/Linux VM path, equivalent to what was done for the Tart guest script and the NixOS guest module.

Update NixOS guest module shell script

Intent: Align the Bash result writer in the NixOS guest-module (used for bare-metal or alternative VM jobs) with the new contract by switching to "completed" and adding schema_version.

Affected files: nix/pikaci/guest-module.nix

Evidence
@@ -205,7 +205,7 @@ in
-      status="passed"
+      status="completed"
@@ -214,6 +214,7 @@ in
       cat > /artifacts/result.json <<EOF
       {
+        "schema_version": 1,

The shell snippet in nix/pikaci/guest-module.nix mirrors the Tart guest script changes:

  1. Success status literal changes from "passed" to "completed".
  2. "schema_version": 1 is inserted into the heredoc JSON.

After this change, all three guest-side result producers (Tart shell, Incus Python, NixOS module shell) emit identical field names and status vocabulary, and all are parseable by decode_runtime_terminal_result on the host.

Add tests for the new contract helpers and rejection of legacy status

Intent: Verify that the factory, encode/decode round-trip, status mapping, and legacy rejection all behave correctly, preventing silent regressions if anyone reintroduces the old "passed" vocabulary.

Affected files: crates/pika-cloud/src/lifecycle.rs, crates/pikaci/src/executor.rs

Evidence
@@ -132,4 +167,46 @@ mod tests {
+    #[test]
+    fn runtime_terminal_result_for_exit_code_uses_completed_on_success() {
+    #[test]
+    fn runtime_terminal_result_helpers_round_trip() {
+    #[test]
+    fn runtime_terminal_result_rejects_legacy_passed_status() {
@@ -2549,6 +2533,23 @@ mod tests {
+    #[test]
+    fn runtime_terminal_result_completed_maps_to_passed_run_status() {
+    #[test]
+    fn tart_guest_script_writes_shared_terminal_result_vocabulary() {

Five new tests lock down the contract:

In crates/pika-cloud/src/lifecycle.rs

TestAssertion
runtime_terminal_result_for_exit_code_uses_completed_on_successExit code 0 → Completed status, correct schema version and message.
runtime_terminal_result_helpers_round_tripEncode then decode of a failure result (exit 9) produces an identical struct.
runtime_terminal_result_rejects_legacy_passed_statusA JSON payload containing "status": "passed" fails deserialization with an "unknown variant" error, ensuring the old vocabulary is hard-rejected.

In crates/pikaci/src/executor.rs

TestAssertion
runtime_terminal_result_completed_maps_to_passed_run_statusrun_status_from_terminal_result maps CompletedRunStatus::Passed.
tart_guest_script_writes_shared_terminal_result_vocabularyThe rendered Tart guest script contains status="completed", "schema_version": 1, and the "status": "$status" interpolation.

Diff