From ed468f63c1cae64cd7fb1b0a3bfac039eeee457c Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:33:03 +0800 Subject: [PATCH] fix(desktop): keep the pairing decision phase through service startup `resume_runtime` publishes `runtime_pairing_required` and then returns Err so no service starts. `start_services` relabelled every error except `runtime_setup_required` as a generic `error`, so the decision survived for well under a poll interval and the first screen rendered the escalated error projection instead of the two operator choices. Name the rule instead of comparing one code inline: a runtime state that published its own phase (`runtime_setup_required`, `runtime_pairing_required`) keeps it, and only unexpected failures are published as errors. The unit test pins both sides of that boundary. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../src-tauri/src/maintenance.rs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs index c5cfbc52d8..f6ede4f275 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -603,6 +603,16 @@ fn pairing_details(bundled: &Value, installed: Option<&Value>, app_version: &str // Shared App/runtime pairing gate for both release startup entrances: the // journal-absent path and the start that just discarded a stale journal may // connect only when the installed runtime pairs with the bundled snapshot. +// A runtime state that already published its own phase must not be relabelled +// by the supervisor's generic error publication: the boot surface renders the +// repair guidance and the operator decision by their own rules. +fn runtime_state_publishes_own_phase(error: &str) -> bool { + matches!( + error, + "runtime_setup_required" | "runtime_pairing_required" + ) +} + fn require_paired_runtime(state: &Maintenance, app: &AppHandle) -> Result<(), String> { let bundled = bundled_runtime::identity(app)?; let installed = @@ -728,7 +738,12 @@ pub fn start_services(app: &AppHandle) -> Result().reconcile_services(|| { if let Err(error) = resume_runtime(app) { - if error != "runtime_setup_required" { + // Runtime states publish the phase that explains them before they + // return: a missing runtime is the repair guidance, and a different + // installed runtime is the operator decision. Relabelling either as + // a generic error would replace the surface that offers the next + // step with a failure notice. + if !runtime_state_publishes_own_phase(&error) { app.state::() .publish("error", json!({"code":error})); } @@ -1194,6 +1209,26 @@ mod tests { ); } + #[test] + fn supervisor_keeps_the_phase_that_explains_a_runtime_state() { + // Both runtime states publish their own phase before resume_runtime + // returns. A generic error publication here would replace the repair + // guidance or the operator decision with a failure notice -- the + // decision would stop rendering its two choices entirely. + for owned in ["runtime_setup_required", "runtime_pairing_required"] { + assert!(runtime_state_publishes_own_phase(owned), "{owned}"); + } + for relabelled in [ + "runtime_identity_mismatch", + "runtime_install_exit_1", + "runtime_install_timeout", + "update_state_invalid", + "service_start_failed", + ] { + assert!(!runtime_state_publishes_own_phase(relabelled), "{relabelled}"); + } + } + #[test] fn stale_journal_start_connects_only_through_the_pairing_gate() { // Stale journal + paired App/runtime: the start may connect.