From 3cc24fe473e115bb5bae18c0229903afb89fee52 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:48:02 +0800 Subject: [PATCH] fix(desktop): restore guarded Chat adoption and responsive startup Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/desktop/loopx-control-plane/README.md | 48 +++++++ .../docs/assets/native-chat-wait-after.png | Bin 0 -> 29925 bytes .../docs/assets/native-chat-wait-before.png | Bin 0 -> 30368 bytes .../loopx-control-plane/src-tauri/src/lib.rs | 6 + .../src-tauri/src/maintenance.rs | 33 ++++- .../src-tauri/src/services.rs | 99 ++++++++++++-- .../src-tauri/src/services_tests.rs | 121 +++++++++++++++++- .../loopx-control-plane/static/boot.js | 16 ++- loopx/canary/module_metric_baseline.json | 2 +- .../native_chat/conversation_bindings.py | 62 ++++----- loopx/chat_server.py | 8 ++ loopx/cli_commands/support_control_update.py | 23 +++- loopx/cli_runtime.py | 4 + loopx/dashboard_launcher.py | 55 ++++---- loopx/runtime_activation.py | 49 ++++++- tests/test_chat_conversation_bindings.py | 64 +++++++++ tests/test_chat_runtime_activation.py | 52 ++++++++ tests/test_chat_server_cors.py | 40 ++++++ tests/test_dashboard_command.py | 25 ++++ .../test_support_control_update_ownership.py | 32 +++++ 20 files changed, 666 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/loopx-control-plane/docs/assets/native-chat-wait-after.png create mode 100644 apps/desktop/loopx-control-plane/docs/assets/native-chat-wait-before.png diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index a3bf74c146..4096e26da3 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -113,6 +113,54 @@ unknown; this is maintenance of the App's own installation, not proof that one source revision is newer. A provably newer runtime remains selected. Both local HTTP services must expose the selected artifact's identity before the workspace opens. +Chat readiness uses `/api/chat/readiness`: the loaded service's frozen runtime +identity and HTTP readiness only. It does not enumerate models, credentials, +Sessions or Goal state, and grants no work authority. Both native and browser +launchers retain the original capabilities fallback for older services. The +native probe keeps its 500 ms deadline, so slow metadata reads cannot multiply +startup probes into more capability-discovery work. Capability availability and +original-conversation authorization are still checked by their normal owners. + +Chat 就绪探针仅读取已加载服务的固定版本身份,不枚举模型、凭据、会话或 Goal, +也不授予执行权限。原生与浏览器启动器保留旧服务的完整能力接口兼容路径;原生探针 +仍为 500 毫秒期限,不通过延长期限掩盖问题。能力是否可用、原会话是否获授权,仍由 +原入口单独实时核验。 + +Opening the selected original conversation is a read-only discovery. It uses a +fresh provider identity and Core's grant check, then re-reads the exact atomic +configuration to reject a concurrent selection or revocation. It does not wait +behind background mutation fences, cache authorization, resume a thread or +submit work. Sending still uses the existing effect-time admission fences. + +打开原管家会话仅做只读发现:实时核验身份及 Core 授权后,再读原子配置,拒绝期间 +发生的选择或撤权变化;不再排队等待后台修改锁,也不缓存授权、恢复线程或提交任务。 +发送消息仍走原有执行时准入与保护锁。 + +On macOS, adopting a different Chat runtime reuses Core's existing managed +listener replacement. The loaded original manager supplies its explicit store +route; the same accepted-work fence protects its queued and active Turns through +restart. A private bootstrap does not need to match a desktop Python template. +While work is pending the window explains the wait and retries automatically. +Unknown manager/store state grants no raw-kill fallback or second consumer. +`loopx update activate --service chat --port 8767` retries this adoption without +installing anything; its receipt is a restart request, not readiness proof. +`loopx update activate` retries the existing managed status/Chat batch. + +macOS 切换 Chat 运行时时复用 Core 已有的受管替换入口,由正在运行的原管理器提供 +明确存储路由,并在重启期间保留排队及在途任务保护。私有启动包装无需匹配桌面的 +Python 模板;任务未结束时窗口说明等待原因并自动重试。来源或任务状态未知时, +不退回直接杀进程,也不启动第二消费者。上述 activate 命令只重试已安装服务的采用, +不重新安装;返回重启请求后仍需独立核验服务身份与就绪状态。 + +The startup recovery view distinguishes accepted work from unverified state. +These before/after images use a synthetic native snapshot to show the copy only; +they do not establish service readiness or request completion. The managed +restart fence and independent readiness readback qualify those separately. + +| Before: a generic connection failure | After: pending work and automatic continuation | +| --- | --- | +| ![Generic startup connection failure](docs/assets/native-chat-wait-before.png) | ![Startup waits for the original request without asking for a restart](docs/assets/native-chat-wait-after.png) | + If anonymous GitHub ancestry lookup is unavailable, the App can reuse an existing GitHub CLI login for the same fixed public-repository GET. It never starts login or requests account permissions. Each tool has a three-second diff --git a/apps/desktop/loopx-control-plane/docs/assets/native-chat-wait-after.png b/apps/desktop/loopx-control-plane/docs/assets/native-chat-wait-after.png new file mode 100644 index 0000000000000000000000000000000000000000..1b5a7050ddba114012d4f103926d2f51769d860e GIT binary patch literal 29925 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∋V-W}V8-h^;L&6Pk`pT|GK&?@0<1 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/loopx-control-plane/docs/assets/native-chat-wait-before.png b/apps/desktop/loopx-control-plane/docs/assets/native-chat-wait-before.png new file mode 100644 index 0000000000000000000000000000000000000000..34aa886ef5ac04d07fb372603efa4596b4fff684 GIT binary patch literal 30368 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs index eb83f60d7f..0cfa9583f4 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -54,6 +54,12 @@ fn maintenance_origin(url: &Url) -> String { // echo verbatim into the boot surface, where it names the recovery panel's // actionable diagnostics instead of a misleading fixed message. fn boot_failure_message(error: &str) -> String { + if error == "chat_work_pending" { + return "管家正在处理请求,完成后会自动连接工作区。".to_string(); + } + if matches!(error, "chat_state_unverified" | "chat_service_unverified") { + return "正在等待安全连接管家服务;请展开「恢复与更新」查看诊断。".to_string(); + } // The pairing decision is not a failure: the window is waiting for the // operator to choose between updating the App and aligning the CLI. if error == "runtime_pairing_required" { 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 d26df492fa..471d0956a5 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -430,7 +430,15 @@ impl Maintenance { Some("error" | "runtime_required" | "runtime_pairing_required") ); if !runtime_error { - self.publish("service_error", json!({"code":"service_start_failed"})); + // These exact codes come from Core's activation receipt; + // arbitrary service diagnostics remain private. + let code = match error.as_str() { + "chat_work_pending" => "chat_work_pending", + "chat_state_unverified" => "chat_state_unverified", + "chat_service_unverified" => "chat_service_unverified", + _ => "service_start_failed", + }; + self.publish("service_error", json!({"code":code})); } Err(error) } @@ -2302,6 +2310,29 @@ mod tests { assert_eq!(state.snapshot.lock().unwrap()["phase"], "ready"); } + #[test] + fn service_wait_keeps_only_the_coarse_activation_cause() { + for code in [ + "chat_work_pending", + "chat_state_unverified", + "chat_service_unverified", + ] { + let state = Maintenance::default(); + assert!(state.reconcile_services::<()>(|| Err(code.into())).is_err()); + assert_eq!(state.snapshot.lock().unwrap()["details"]["code"], code); + assert_eq!(state.reconcile_services(|| Ok(())).unwrap(), Some(())); + assert_eq!(state.snapshot.lock().unwrap()["phase"], "ready"); + } + let state = Maintenance::default(); + assert!(state + .reconcile_services::<()>(|| Err("private manager command".into())) + .is_err()); + assert_eq!( + state.snapshot.lock().unwrap()["details"]["code"], + "service_start_failed" + ); + } + #[test] fn service_retry_cannot_race_maintenance_or_restart() { let state = Maintenance::default(); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs index 7e16c5ced2..979e54e36a 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -55,7 +55,7 @@ impl ServiceKind { fn probe_path(self) -> &'static str { match self { Self::Status => "/?readiness=1", - Self::Chat => "/api/chat/capabilities", + Self::Chat => "/api/chat/readiness", } } @@ -659,6 +659,25 @@ fn terminate_verified_listener( loopx_executable: &str, port: u16, ) -> Result<(), ServiceError> { + #[cfg(target_os = "macos")] + if kind == ServiceKind::Chat { + // Core owns the loaded manager's route, accepted-work fence and exact + // listener PID. A desktop Python-template match cannot authorize + // stopping a Chat worker, including one using a private bootstrap. + let mut command = Command::new(loopx_executable); + configure_runtime_environment(&mut command); + command.args([ + "--format", + "json", + "update", + "activate", + "--service", + "chat", + "--port", + &port.to_string(), + ]); + return managed_chat_activation_result(timed_output_with_timeout(command, STARTUP_TIMEOUT)); + } #[cfg(not(windows))] { let processes = listener_processes(port)?; @@ -694,6 +713,37 @@ fn terminate_verified_listener( } } +#[cfg(any(target_os = "macos", test))] +fn managed_chat_activation_result( + output: Option, +) -> Result<(), ServiceError> { + let payload = output + .as_ref() + .and_then(|result| serde_json::from_slice::(&result.stdout).ok()); + let Some(payload) = payload.filter(|value| { + value["schema_version"] == "loopx_runtime_service_activation_v1" + && value["service"] == "chat" + }) else { + // An older Core or lost result grants no fallback raw kill. The next + // HTTP read observes whether the original manager actually recovered. + return Err(ServiceError("chat_service_unverified".into())); + }; + match (payload["ok"].as_bool(), payload["status"].as_str()) { + (Some(true), Some("restart_requested" | "not_running")) + if output + .as_ref() + .is_some_and(|result| result.status.success()) => + { + Ok(()) + } + (Some(false), Some("deferred")) => match payload["reason"].as_str() { + Some("chat_work_pending") => Err(ServiceError("chat_work_pending".into())), + _ => Err(ServiceError("chat_state_unverified".into())), + }, + _ => Err(ServiceError("chat_service_unverified".into())), + } +} + #[cfg(not(windows))] fn listener_processes(port: u16) -> Result, ServiceError> { let selector = format!("-iTCP:{port}"); @@ -1216,20 +1266,41 @@ fn probe_on_port( port: u16, expected_runtime_identity: Option<&serde_json::Value>, ) -> Probe { + let mut response = match read_probe_response(port, kind.probe_path()) { + Ok(response) => response, + Err(probe) => return probe, + }; + if kind == ServiceKind::Chat + && response + .lines() + .next() + .and_then(|line| line.split_ascii_whitespace().nth(1)) + == Some("404") + { + // A legacy Core has no lightweight endpoint. Keep its original + // fingerprint/identity checks; new services never enumerate metadata. + response = match read_probe_response(port, "/api/chat/capabilities") { + Ok(response) => response, + Err(probe) => return probe, + }; + } + classify_response(kind, &response, expected_runtime_identity) +} + +fn read_probe_response(port: u16, path: &str) -> Result { let address = SocketAddr::from(([127, 0, 0, 1], port)); let mut stream = match TcpStream::connect_timeout(&address, PROBE_TIMEOUT) { Ok(stream) => stream, - Err(_) => return Probe::Unavailable, + Err(_) => return Err(Probe::Unavailable), }; let _ = stream.set_read_timeout(Some(PROBE_TIMEOUT)); let _ = stream.set_write_timeout(Some(PROBE_TIMEOUT)); let request = format!( "GET {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", - kind.probe_path(), - port + path, port ); if stream.write_all(request.as_bytes()).is_err() { - return Probe::Unresponsive; + return Err(Probe::Unresponsive); } let mut response = String::new(); if stream @@ -1237,12 +1308,12 @@ fn probe_on_port( .read_to_string(&mut response) .is_err() { - return Probe::Unresponsive; + return Err(Probe::Unresponsive); } if response.len() as u64 > MAX_PROBE_RESPONSE_BYTES { - return Probe::Foreign; + return Err(Probe::Foreign); } - classify_response(kind, &response, expected_runtime_identity) + Ok(response) } fn classify_response( @@ -1266,6 +1337,18 @@ fn classify_response( let Ok(payload) = serde_json::from_str::(body) else { return Probe::Foreign; }; + if kind == ServiceKind::Chat && payload["schema_version"] == "loopx_chat_readiness_v1" { + if payload["ok"] != true || payload["state"] != "ready" { + return Probe::Foreign; + } + return if expected_runtime_identity + .is_some_and(|expected| payload.get("runtime_identity") != Some(expected)) + { + Probe::Stale + } else { + Probe::Matching + }; + } let (field, expected) = kind.expected_fingerprint(); if payload .as_object() diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs index 1d0b5d9121..ec41b987ff 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs @@ -460,9 +460,11 @@ fn service_supervisor_reuses_matching_replaces_stale_and_rejects_foreign() { stale_executable.to_string_lossy().as_ref(), port, ); - if confirmed { + if confirmed && !cfg!(target_os = "macos") { result.expect("replace a confirmed but unresponsive LoopX service"); } else { + // On macOS even a matching Chat command needs Core's original + // manager and accepted-work proof. A fixture socket grants none. assert!(result.is_err()); assert!(silent .try_wait() @@ -486,6 +488,123 @@ fn pending_service_label_names_one_service_and_the_concurrent_set() { assert_eq!(ServiceKind::pending_label(&[]), "local"); } +#[test] +fn chat_activation_receipts_preserve_busy_unknown_and_exit_status() { + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; + #[cfg(windows)] + use std::os::windows::process::ExitStatusExt; + let output = |payload: serde_json::Value, succeeded| { + Some(std::process::Output { + status: std::process::ExitStatus::from_raw(if succeeded { 0 } else { 256 }), + stdout: serde_json::to_vec(&payload).unwrap(), + stderr: Vec::new(), + }) + }; + let good = serde_json::json!({"schema_version":"loopx_runtime_service_activation_v1", + "service":"chat","ok":true,"status":"restart_requested","readiness_verified":false}); + assert!(managed_chat_activation_result(output(good.clone(), true)).is_ok()); + assert!(managed_chat_activation_result(output(good.clone(), false)).is_err()); + for (reason, expected) in [ + ("chat_work_pending", "chat_work_pending"), + ("chat_state_unverified", "chat_state_unverified"), + ("private diagnostic", "chat_state_unverified"), + ] { + let receipt = serde_json::json!({"schema_version":"loopx_runtime_service_activation_v1", + "service":"chat","ok":false,"status":"deferred","reason":reason}); + assert_eq!( + managed_chat_activation_result(output(receipt, false)) + .unwrap_err() + .to_string(), + expected + ); + } + for (key, value) in [ + ("schema_version", "unknown"), + ("service", "status"), + ("status", "unknown"), + ] { + let mut receipt = good.clone(); + receipt[key] = value.into(); + assert_eq!( + managed_chat_activation_result(output(receipt, true)) + .unwrap_err() + .to_string(), + "chat_service_unverified" + ); + } + assert!(managed_chat_activation_result(None).is_err()); +} + +#[test] +fn chat_readiness_retains_identity_and_state_checks() { + let identity = serde_json::json!({"source_revision":"current"}); + let ready = serde_json::json!({"ok":true,"schema_version":"loopx_chat_readiness_v1", + "state":"ready","runtime_identity":identity}); + let response = |body: &serde_json::Value| format!("HTTP/1.1 200 OK\r\n\r\n{body}"); + assert_eq!( + classify_response(ServiceKind::Chat, &response(&ready), Some(&identity)), + Probe::Matching + ); + assert_eq!( + classify_response( + ServiceKind::Chat, + &response(&ready), + Some(&serde_json::json!({"source_revision":"other"})) + ), + Probe::Stale + ); + for state in [serde_json::Value::Null, "unknown".into(), false.into()] { + let mut invalid = ready.clone(); + invalid["state"] = state; + assert_eq!( + classify_response(ServiceKind::Chat, &response(&invalid), Some(&identity)), + Probe::Foreign + ); + } +} + +#[test] +fn legacy_chat_readiness_uses_the_original_capabilities_fallback() { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let identity = serde_json::json!({"source_revision":"current"}); + let expected = identity.clone(); + let responder = thread::spawn(move || { + for path in ["/api/chat/readiness", "/api/chat/capabilities"] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut buffer = [0; 512]; + let read = stream.read(&mut buffer).unwrap(); + assert!(read > 0); + bytes.extend_from_slice(&buffer[..read]); + } + assert!(String::from_utf8(bytes) + .unwrap() + .starts_with(&format!("GET {path} HTTP/1.1"))); + let response = if path.ends_with("readiness") { + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into() + } else { + let body = serde_json::json!({"ok":true,"schema_version":"loopx_chat_capabilities_v1","runtime_identity":identity}).to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + }; + stream.write_all(response.as_bytes()).unwrap(); + } + }); + assert_eq!( + probe_on_port(ServiceKind::Chat, port, Some(&expected)), + Probe::Matching + ); + responder.join().unwrap(); +} + #[test] fn service_connections_run_concurrently_and_name_the_remaining_service() { // Concurrency is the contract, not a timing coincidence: each connection diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index ac63297059..2f9eaf1264 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -23,8 +23,15 @@ function renderStartup(result) { ready: "本地服务已就绪,正在打开工作区", service_error: "本地服务连接失败,正在等待重试", }; + const serviceTitles = { + chat_work_pending: "管家正在处理请求,完成后自动连接", + chat_state_unverified: "正在核验管家任务状态,稍后自动重试", + chat_service_unverified: "正在确认管家服务切换,稍后自动重试", + }; if (Object.hasOwn(titles, state?.phase)) { - status.textContent = titles[state.phase]; + status.textContent = state.phase === "service_error" + ? serviceTitles[state.details?.code] || titles.service_error + : titles[state.phase]; panel.dataset.state = "loading"; panel.setAttribute("aria-busy", "true"); } @@ -39,7 +46,9 @@ function updateStartupElapsed() { const elapsed = lastTiming === null ? performance.now() - pageStarted : lastTiming + performance.now() - timingObservedAt; const seconds = Math.floor(elapsed / 1000); bootElapsed.textContent = `${lastTiming === null ? "此页面已等待" : "启动已用时"} ${seconds} 秒`; - bootDetail.textContent = bootState?.phase === "installing_runtime" + bootDetail.textContent = bootState?.details?.code === "chat_work_pending" + ? "已有请求会继续执行,无需反复重开 App。" + : bootState?.phase === "installing_runtime" ? "正在准备最新可用组件,完成后会自动打开工作区。" : bootState?.phase === "service_error" ? "启动器会自动重试;可展开「恢复与更新」查看诊断。" @@ -92,6 +101,9 @@ const labels = { error: "更新未完成。请重试检查,或修复当前版本。Goal 数据不会被删除。", }; const errors = { + chat_work_pending: "管家正在处理已有请求,完成后会自动切换服务并连接工作区。无需强退或重复发送。", + chat_state_unverified: "暂时无法核验管家服务的任务状态。连接会自动重试;请保留诊断信息。", + chat_service_unverified: "管家服务切换尚未确认。连接会自动重试;可保留诊断信息,或通过原安装方式更新 LoopX。", desktop_status_unavailable: "无法读取 App 诊断状态。请重启 App;若仍失败,请重新安装完整 App。", runtime_setup_required: "App 与本机运行时不匹配,或找不到安装身份。请修复当前版本,成功后重启。", runtime_bundle_missing: "App 缺少配套运行时文件。请重新下载完整 App。", diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index c31fcc6bc9..b9e6f7024c 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -41,7 +41,7 @@ "loopx/chat_server.py": { "any_count": 25, "dict_any_count": 0, - "lines": 2026 + "lines": 2034 }, "loopx/chat_store.py": { "lines": 2066 diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index 6ef4e50fc9..0844d65834 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -180,10 +180,9 @@ def app_steward_target(self) -> dict[str, Any]: if not sessions: raise ValueError("the original steward Session is unavailable") session = max(sessions, key=lambda item: (item.get("last_activity_at") or "", item["session_id"])) - with exclusive_file_lock(self.path, operation="read_app_steward_target"): - if self.read().get("app_steward_binding_id") != selected: - raise ValueError("App steward selection changed during discovery") - return {"binding_id": selected, "session_id": session["session_id"]} + if self.read().get("app_steward_binding_id") != selected: + raise ValueError("App steward selection changed during discovery") + return {"binding_id": selected, "session_id": session["session_id"]} def _revoke_delivery_sources(self, binding_id: str) -> None: if self.controller is not None: @@ -309,8 +308,10 @@ def local_sessions(self, binding_id: str, *, app_steward_entry: bool = False) -> def local_session_snapshot(self, binding_id: str, *, app_steward_entry: bool = False) -> dict[str, Any]: """Read original targets; metadata hints never replace Core's grant. - Provider IO and store inventory stay outside the publication fence. - Discovery neither restores a thread nor creates a Session or Turn. + This read-only snapshot does not wait for a mutation fence. Core checks + the observed grant, and a final canonical read rejects concurrent grant + changes. Discovery neither restores a thread nor creates work; actual + submissions retain their fresh identity and effect-time grant fences. """ if self.controller is None: raise ValueError("the bound conversation store is unavailable") @@ -328,30 +329,31 @@ def local_session_snapshot(self, binding_id: str, *, app_steward_entry: bool = F and session[key].get("binding_id") == binding_id for key in ("project_context", "steward_context")): sessions.append(session) - with exclusive_file_lock(self.path, operation="read_local_bound_sessions"): - current = self.read() - if next((item for item in current["bindings"] if item["binding_id"] == binding_id), None) != row: - raise ValueError("conversation binding changed during identity verification") - projects = self.projects.available() - result = self._core("collaboration.conversation.local_sessions", { - "current": current, "binding_id": binding_id, "observation": observation, - "available_projects": projects, - "sessions": [self._session_observation(session) for session in sessions], - **({"app_steward_entry": True} if app_steward_entry else {}), - **({"available_goal_ids": self.goal_scope_ids(row)} if row.get("goal_scope") == "all_registered" else {}), - }) - # Build the lookup only after Core has rejected duplicate identity; - # an IO dictionary must not silently turn ambiguity into one target. - by_id = {session["session_id"]: session for session in sessions} - project = next(project for project in projects if project["project_ref"] == row["project_ref"]) - # Project this exact Core-verified source under the same fence. - # Opening a conversation must not enumerate settings, unrelated - # Agent candidates, listener health or stop-recovery inventories. - return {"sessions": [store.public_session(by_id[sid]) for sid in result["session_ids"]], - "conversation_source": {"binding_id": binding_id, "app_ref": row["transport_ref"], - "context_kind": row["context_kind"], "audience": row.get("audience", "owner"), - "project_title": Path(project["workspace_path"]).name, "context_available": True, - "executor_endpoint_id": row["executor_endpoint_id"]}} + current = self.read() + if next((item for item in current["bindings"] if item["binding_id"] == binding_id), None) != row: + raise ValueError("conversation binding changed during identity verification") + projects = self.projects.available() + result = self._core("collaboration.conversation.local_sessions", { + "current": current, "binding_id": binding_id, "observation": observation, + "available_projects": projects, + "sessions": [self._session_observation(session) for session in sessions], + **({"app_steward_entry": True} if app_steward_entry else {}), + **({"available_goal_ids": self.goal_scope_ids(row)} if row.get("goal_scope") == "all_registered" else {}), + }) + # Build the lookup only after Core has rejected duplicate identity; + # an IO dictionary must not silently turn ambiguity into one target. + by_id = {session["session_id"]: session for session in sessions} + project = next(project for project in projects if project["project_ref"] == row["project_ref"]) + payload = {"sessions": [store.public_session(by_id[sid]) for sid in result["session_ids"]], + "conversation_source": {"binding_id": binding_id, "app_ref": row["transport_ref"], + "context_kind": row["context_kind"], "audience": row.get("audience", "owner"), + "project_title": Path(project["workspace_path"]).name, "context_available": True, + "executor_endpoint_id": row["executor_endpoint_id"]}} + # Bind the projection to the exact atomic configuration Core checked. + # This is not an execution grant or a cached provider observation. + if self.read() != current: + raise ValueError("conversation binding changed during discovery") + return payload def goal_scope_ids(self, binding: dict[str, Any]) -> list[str]: if binding.get("goal_scope") != "all_registered": diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 0e30d2e13b..aed462bbb7 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -124,6 +124,7 @@ DEFAULT_CHAT_PATH = "/chat/" DEFAULT_CHAT_STATUS_PATH = "/status.json" CHAT_CAPABILITIES_PATH = "/api/chat/capabilities" +CHAT_READINESS_PATH = "/api/chat/readiness" CHAT_ENDPOINTS_PATH = "/api/chat/endpoints" CHAT_AGENT_MODEL_OPTIONS_PATH = "/api/chat/agent-model-options" CHAT_SESSIONS_PATH = "/api/chat/sessions" @@ -1653,6 +1654,13 @@ def do_GET(self) -> None: if path == "/healthz": self._send_json({"ok": True}) return + if path == CHAT_READINESS_PATH: + # Readiness pairs the frontend with this loaded service artifact. + # Model/provider availability and original-conversation grants are + # separate fresh reads, never prerequisites for this cheap probe. + self._send_json({"ok": True, "schema_version": "loopx_chat_readiness_v1", + "state": "ready", "runtime_identity": self.server.runtime_identity}) + return if path == CHAT_CAPABILITIES_PATH: # The steward section is composed by its own owner so the model # arguments, the availability verdict and the readback cannot diff --git a/loopx/cli_commands/support_control_update.py b/loopx/cli_commands/support_control_update.py index e230c9cd30..4e7c8a0b7c 100644 --- a/loopx/cli_commands/support_control_update.py +++ b/loopx/cli_commands/support_control_update.py @@ -68,7 +68,7 @@ def register_update_command( update_parser.add_argument( "update_action", nargs="?", - choices=(*tuple(action.value for action in UpdateAction), "status"), + choices=(*tuple(action.value for action in UpdateAction), "status", "activate"), help="Explicit intent: check (read only), plan (read only), or apply (mutating).", ) update_mode = update_parser.add_mutually_exclusive_group() @@ -104,6 +104,10 @@ def register_update_command( update_parser.add_argument("--allow-downgrade", action="store_true", help="Explicitly accept an older, diverged or unverified snapshot target.") update_parser.add_argument("--background", action="store_true", help="Start or observe one durable update and return its operation id immediately.") update_parser.add_argument("--operation-id", help="Read a previous update with `update status --operation-id ID`.") + update_parser.add_argument("--service", choices=("all", "chat"), + help="With update activate, retry installed managed services (default: all).") + update_parser.add_argument("--port", type=int, + help="With update activate --service chat, select its loopback port (default: 8767).") update_parser.add_argument( "--archive-url", help="Explicit tarball URL passed to the installer as LOOPX_ARCHIVE_URL.", @@ -140,6 +144,20 @@ def handle_update_command( progress = UpdateProgress(json_output=output_format(args) == "json", pending=os.environ.get("LOOPX_UPDATE_WORKER") != "1") try: + if args.update_action == "activate": + if upgrade or any((args.check, args.dry_run, args.execute, args.rollback, + args.repo, args.ref, args.archive_url, args.installed_doctor_json, + args.operation_id, args.source, args.allow_downgrade, args.background)): + raise ValueError("use update activate only with --service and --port; no installation is performed") + if args.port is not None and args.service != "chat": + raise ValueError("--port requires update activate --service chat") + from ..runtime_activation import activate_runtime_services + payload = activate_runtime_services(service=args.service or "all", port=args.port if args.port is not None else 8767) + print_payload(payload, output_format(args), lambda value: + f"Managed service activation: {value['status']} ({value.get('reason') or 'verify service readiness'})") + return 0 if payload.get("ok") else 1 + if getattr(args, "service", None) is not None or getattr(args, "port", None) is not None: + raise ValueError("--service and --port require update activate") if args.update_action == "status": if any((args.check, args.dry_run, args.execute, args.rollback, args.repo, args.ref, args.archive_url, getattr(args, "source", False), getattr(args, "allow_downgrade", False), getattr(args, "background", False))): @@ -186,7 +204,8 @@ def handle_update_command( payload = operation if getattr(args, "background", False) else observe( operation["operation_id"], json_output=output_format(args) == "json") if getattr(args, "background", False): - renderer = lambda value: f"Update {value['operation_id']}: {value['status']}\nRead back with loopx update status --operation-id {value['operation_id']}" + def renderer(value): + return f"Update {value['operation_id']}: {value['status']}\nRead back with loopx update status --operation-id {value['operation_id']}" elif upgrade: from .support_control_upgrade import render_upgrade_markdown renderer = render_upgrade_markdown diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index e25a6db73e..5909da9d18 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -173,6 +173,10 @@ def resolve_cli_registry( registry_was_configured = user_supplied_registry(raw_argv) or bool( os.environ.get("LOOPX_REGISTRY") ) + if args.command == "update" and getattr(args, "update_action", None) == "activate": + # Service activation reads its route from the loaded original manager. + # It neither opens Goal authority nor guesses a default Chat store. + return registry_path, registry_was_configured if args.command == "quota" and getattr(args, "quota_command", None) in {"context-read", "replan-view"}: from .cli_commands.context_machine import has_explicit_context_read_source diff --git a/loopx/dashboard_launcher.py b/loopx/dashboard_launcher.py index 8bc50a0754..5ea90b1118 100644 --- a/loopx/dashboard_launcher.py +++ b/loopx/dashboard_launcher.py @@ -50,32 +50,31 @@ def _probe_existing_chat( # Imported here rather than at module level: the launcher probes a port that # may hold no LoopX process at all, and the server module's import graph # costs ~110ms on this path. - from .chat_server import CHAT_CAPABILITIES_PATH + from .chat_server import CHAT_CAPABILITIES_PATH, CHAT_READINESS_PATH - try: - connection = http.client.HTTPConnection( - host, - port, - timeout=CHAT_PROBE_TIMEOUT_SECONDS, - ) + paths = ([CHAT_CAPABILITIES_PATH] if goal_subagent_configuration_enabled is not None + else [CHAT_READINESS_PATH, CHAT_CAPABILITIES_PATH]) + for path in paths: try: - connection.request( - "GET", - CHAT_CAPABILITIES_PATH, - headers={"Connection": "close"}, + connection = http.client.HTTPConnection( + host, port, timeout=CHAT_PROBE_TIMEOUT_SECONDS, ) - response = connection.getresponse() - payload = response.read(MAX_PROBE_RESPONSE_BYTES) - status = response.status - finally: - connection.close() - except (ConnectionRefusedError, TimeoutError): - # Some native Windows execution environments time out instead of - # returning WSAECONNREFUSED for an unused loopback port. Treat that as - # non-reusable and let the later server bind remain authoritative. - return "unavailable" - except (OSError, http.client.HTTPException): - return "foreign" + try: + connection.request("GET", path, headers={"Connection": "close"}) + response = connection.getresponse() + payload = response.read(MAX_PROBE_RESPONSE_BYTES) + status = response.status + finally: + connection.close() + except (ConnectionRefusedError, TimeoutError): + # Some Windows hosts time out for an unused loopback port. The + # later server bind remains authoritative; this is not reuse. + return "unavailable" + except (OSError, http.client.HTTPException): + return "foreign" + if status == 404 and path == CHAT_READINESS_PATH: + continue # Compatibility with an older installed Chat service. + break if status != 200: return "foreign" try: @@ -84,11 +83,13 @@ def _probe_existing_chat( return "foreign" if not isinstance(capabilities, dict): return "foreign" - if ( - capabilities.get("ok") is not True - or capabilities.get("schema_version") != EXPECTED_CHAT_SCHEMA_VERSION - ): + schema = capabilities.get("schema_version") + if capabilities.get("ok") is not True or schema not in { + EXPECTED_CHAT_SCHEMA_VERSION, "loopx_chat_readiness_v1", + } or schema == "loopx_chat_readiness_v1" and capabilities.get("state") != "ready": return "foreign" + if goal_subagent_configuration_enabled is not None and schema != EXPECTED_CHAT_SCHEMA_VERSION: + return "foreign" # Readiness makes no capability/configuration claim. expected_identity = release_runtime_identity(dashboard_release_root()) if capabilities.get("runtime_identity") != expected_identity: return "stale" diff --git a/loopx/runtime_activation.py b/loopx/runtime_activation.py index 998ee372cf..03afc56157 100644 --- a/loopx/runtime_activation.py +++ b/loopx/runtime_activation.py @@ -139,7 +139,10 @@ def managed_chat_activation_guard( yield -def replace_managed_loopx_chat(host: str, port: int, *, timeout_seconds: float = 5.0) -> str: +def replace_managed_loopx_chat( + host: str, port: int, *, timeout_seconds: float = 5.0, + restart_managed: bool = False, +) -> str: """Replace the exact managed listener only while its accepted work is idle. The listener's live manager supplies its route, never the new command's @@ -187,6 +190,17 @@ def replace_managed_loopx_chat(host: str, port: int, *, timeout_seconds: float = deadline = time.monotonic() + max(0.1, timeout_seconds) while time.monotonic() < deadline: if pid not in _listener_pids(port): + if restart_managed: + # Keep the same ingress fence through the original + # manager's restart. Desktop never starts a replacement + # consumer or infers ownership from a Python template. + started = subprocess.run( + ["launchctl", "kickstart", f"gui/{os.getuid()}/{plist.stem}"], + capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=5, + ) + if started.returncode != 0: + raise RuntimeActivationFailed([], labels) return existing time.sleep(0.1) raise RuntimeError("the managed Chat listener did not stop after SIGTERM; no stronger signal was sent") @@ -197,6 +211,39 @@ def replace_managed_loopx_chat(host: str, port: int, *, timeout_seconds: float = raise RuntimeActivationDeferred("chat_state_unverified", labels) from exc +def activate_runtime_services(*, service: str = "all", port: int = 8767) -> dict[str, Any]: + """Retry installed-service adoption without reinstalling or running work. + + The desktop Chat caller uses the existing verified listener/manager/store + owner. A version mismatch is not permission to kill a busy worker. + Readiness and the selected artifact's identity still need a fresh HTTP read. + """ + if service not in {"all", "chat"} or not 1 <= port <= 65535: + raise ValueError("select all or chat with a valid loopback port") + payload: dict[str, Any] = { + "schema_version": "loopx_runtime_service_activation_v1", + "service": service, "readiness_verified": False, + } + if sys.platform != "darwin": + return {**payload, "ok": False, "status": "unsupported", + "reason": "managed_service_activation_unavailable"} + try: + if service == "chat": + observed = replace_managed_loopx_chat("127.0.0.1", port, restart_managed=True) + status = "not_running" if observed == "unavailable" else "restart_requested" + else: + restarted = restart_managed_loopx_services() + payload["restarted_services"] = restarted + status = "restart_requested" if restarted else "not_running" + return {**payload, "ok": True, "status": status} + except RuntimeActivationDeferred as exc: + return {**payload, "ok": False, "status": "deferred", "reason": str(exc)} + except (RuntimeActivationFailed, OSError, ValueError, RuntimeError): + # Public diagnostics carry a coarse cause, never manager commands, + # local paths or the contents of accepted messages. + return {**payload, "ok": False, "status": "failed", "reason": "chat_service_unverified"} + + def restart_managed_loopx_services() -> list[str]: """Switch managed macOS services only with no accepted Chat work outstanding. diff --git a/tests/test_chat_conversation_bindings.py b/tests/test_chat_conversation_bindings.py index b9cc99c56b..990a135910 100644 --- a/tests/test_chat_conversation_bindings.py +++ b/tests/test_chat_conversation_bindings.py @@ -355,6 +355,70 @@ def test_app_steward_corrupt_legacy_configuration_never_falls_back(tmp_path): assert bindings.path.read_bytes() == before +def test_app_steward_snapshot_does_not_wait_for_background_mutation(steward): # noqa: F811 + import threading + from concurrent.futures import ThreadPoolExecutor + from loopx.file_lock import exclusive_file_lock + + store, runtime, provider, transport, binding, capture, _ = steward + event = provider.event("steward-app", "snapshot-original", "Read the workspace") + transport.admit("steward-app", event) + request = transport.core.read_request(identity_ref(binding["provider_ref"], event["message_id"])) + sid = request["session_id"] + assert runtime.wait_for_turn(session_id=sid, turn_id=request["turn_id"], timeout_sec=10)["status"] == "completed" + bindings = transport.bindings + bindings.select_app_steward(binding["binding_id"], expected_revision=bindings.read()["revision"]) + expected = {"binding_id": binding["binding_id"], "session_id": sid} + assert bindings.app_steward_target() == expected + before = capture.read_bytes(), store.messages(sid), bindings.path.read_bytes() + held, release = threading.Event(), threading.Event() + + def background_inspection(): + with runtime.lock, exclusive_file_lock(bindings.path, operation="inspect_native_steward_turn"): + held.set() + assert release.wait(10) + + with ThreadPoolExecutor(max_workers=2) as workers: + inspection = workers.submit(background_inspection) + try: + assert held.wait(2) + target = workers.submit(bindings.app_steward_target) + assert target.result(timeout=.75) == expected + assert not release.is_set(), "Snapshot discovery must complete while inspection is blocked" + finally: + release.set() + inspection.result(timeout=2) + assert (capture.read_bytes(), store.messages(sid), bindings.path.read_bytes()) == before + + +@pytest.mark.parametrize("change", ["selection", "revocation"]) +def test_app_steward_snapshot_rechecks_grant_after_core_discovery(steward, monkeypatch, change): # noqa: F811 + store, runtime, provider, transport, binding, capture, _ = steward + event = provider.event("steward-app", "snapshot-race", "Read the workspace") + transport.admit("steward-app", event) + request = transport.core.read_request(identity_ref(binding["provider_ref"], event["message_id"])) + sid = request["session_id"] + assert runtime.wait_for_turn(session_id=sid, turn_id=request["turn_id"], timeout_sec=10)["status"] == "completed" + bindings = transport.bindings + bindings.select_app_steward(binding["binding_id"], expected_revision=bindings.read()["revision"]) + before = capture.read_bytes(), store.messages(sid) + core = bindings._core + + def change_after_discovery(operation, params, **kwargs): + result = core(operation, params, **kwargs) + if operation == "collaboration.conversation.local_sessions": + if change == "selection": + bindings.select_app_steward(None, expected_revision=bindings.read()["revision"]) + else: + bindings.disconnect(binding["binding_id"], expected_revision=bindings.read()["revision"]) + return result + + monkeypatch.setattr(bindings, "_core", change_after_discovery) + with pytest.raises(ValueError, match="changed"): + bindings.app_steward_target() + assert (capture.read_bytes(), store.messages(sid)) == before + + def _request_for_event(transport, event): binding = transport._binding("notes-app") return transport.core.read_request( diff --git a/tests/test_chat_runtime_activation.py b/tests/test_chat_runtime_activation.py index 8d244d92fc..7f773953f2 100644 --- a/tests/test_chat_runtime_activation.py +++ b/tests/test_chat_runtime_activation.py @@ -71,6 +71,58 @@ def content_hashes(root): } +@pytest.mark.parametrize("state", ["queued", "running", "completed", "unknown"]) +def test_desktop_activation_uses_original_manager_and_accepted_work(services, monkeypatch, state): + from loopx import dashboard_launcher, runtime_activation + runtime, agents, calls = services + # A legitimate private bootstrap need not carry the release launcher's + # Python source text. The loaded manager and its explicit route own it. + (agents / "com.loopx.chat.plist").write_bytes(plistlib.dumps({"ProgramArguments": [ + "/bin/zsh", "-c", shlex.join(["exec", "private-host", "--runtime-root", str(runtime), "chat"])]})) + store = ChatSessionStore(runtime) + sid = session(store) + turn, _ = store.create_queued_turn(sid, client_turn_id="original", message="retain this input") + store.update_turn(sid, turn["turn_id"], status=state) + before = content_hashes(store.root) + listening, signals = [True], [] + monkeypatch.setattr(dashboard_launcher, "_probe_existing_chat", lambda *_a: "stale") + monkeypatch.setattr(dashboard_launcher, "_listener_pids", lambda _port: [12345] if listening[0] else []) + monkeypatch.setattr(dashboard_launcher, "_is_same_user_loopx_chat_process", lambda _pid: True) + monkeypatch.setattr(runtime_activation, "_managed_service_pid", lambda *_a, **_k: 12345) + + def stop(pid, signal): + signals.append((pid, signal)) + listening[0] = False + monkeypatch.setattr(runtime_activation.os, "kill", stop) + result = runtime_activation.activate_runtime_services(service="chat") + assert content_hashes(store.root) == before + assert result["readiness_verified"] is False + if state == "completed": + assert result["ok"] and result["status"] == "restart_requested" + assert len(signals) == 1 and signals[0][0] == 12345 + assert calls == [["launchctl", "kickstart", f"gui/{runtime_activation.os.getuid()}/com.loopx.chat"]] + else: + assert not result["ok"] and result["status"] == "deferred" + assert result["reason"] == ("chat_state_unverified" if state == "unknown" else "chat_work_pending") + assert not signals and not calls + + +def test_desktop_activation_rechecks_loaded_manager_before_signalling(services, monkeypatch): + from loopx import dashboard_launcher, runtime_activation + runtime, _agents, calls = services + ChatSessionStore(runtime) + monkeypatch.setattr(dashboard_launcher, "_probe_existing_chat", lambda *_a: "stale") + monkeypatch.setattr(dashboard_launcher, "_listener_pids", lambda _port: [12345]) + monkeypatch.setattr(dashboard_launcher, "_is_same_user_loopx_chat_process", lambda _pid: True) + identities = iter([12345, 54321]) + monkeypatch.setattr(runtime_activation, "_managed_service_pid", lambda *_a, **_k: next(identities)) + with mock.patch.object(runtime_activation.os, "kill") as stop: + result = runtime_activation.activate_runtime_services(service="chat") + assert result["reason"] == "chat_state_unverified" + stop.assert_not_called() + assert not calls + + @pytest.mark.parametrize( "state", ["queued", "starting", "running", "completing", "interrupting"] ) diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 6374577205..2585bb185a 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -5,6 +5,7 @@ import json import tempfile import threading +import time from pathlib import Path import pytest @@ -115,6 +116,45 @@ def test_chat_capabilities_expose_public_runtime_identity() -> None: server.server_close() +def test_readiness_is_independent_of_blocked_capability_discovery(monkeypatch): + from loopx.dashboard_launcher import _probe_existing_chat + server, thread = _start_server() + entered, release = threading.Event(), threading.Event() + requests, results = [], [] + + def blocked(): + requests.append("capabilities") + entered.set() + assert release.wait(5) + return [] + monkeypatch.setattr(server.runtime_controller, "capabilities", blocked) + def full_read(): + response = _request(server.server_port, method="GET", origin=None) + results.append(response.read()) + reader = threading.Thread(target=full_read) + reader.start() + try: + assert entered.wait(3) + start = time.monotonic() + assert _probe_existing_chat("127.0.0.1", server.server_port) == "matching" + assert time.monotonic() - start < 0.5 + response = _request(server.server_port, method="GET", origin=None, path="/api/chat/readiness") + payload = json.loads(response.read()) + assert payload == {"ok": True, "schema_version": "loopx_chat_readiness_v1", + "state": "ready", "runtime_identity": server.runtime_identity} + assert requests == ["capabilities"] and not results + denied = _request(server.server_port, method="GET", origin="https://foreign.example", path="/api/chat/readiness") + assert denied.status == 403 + denied.read() + finally: + release.set() + reader.join(5) + server.shutdown() + thread.join(5) + server.server_close() + assert len(results) == 1 + + def test_chat_keeps_its_startup_identity_after_package_replacement(monkeypatch: pytest.MonkeyPatch) -> None: current = {"schema_version": "loopx_runtime_identity_v1", "package_fingerprint": "sha256:original"} monkeypatch.setattr("loopx.chat_server.release_runtime_identity", lambda: dict(current)) diff --git a/tests/test_dashboard_command.py b/tests/test_dashboard_command.py index b44c0da44f..51231805cd 100644 --- a/tests/test_dashboard_command.py +++ b/tests/test_dashboard_command.py @@ -261,6 +261,31 @@ def test_probe_existing_chat_accepts_exact_loopx_fingerprint() -> None: thread.join(timeout=2) +def test_probe_legacy_chat_falls_back_once_and_keeps_capability_checks(): + from loopx.dashboard_launcher import _probe_existing_chat + paths = [] + class Legacy(_CapabilitiesHandler): + def do_GET(self): + paths.append(self.path) + if self.path == "/api/chat/readiness": + self.send_response(404) + self.end_headers() + else: + super().do_GET() + server, thread = _serve_capabilities(Legacy) + try: + assert _probe_existing_chat("127.0.0.1", server.server_port) == "matching" + assert paths == ["/api/chat/readiness", "/api/chat/capabilities"] + paths.clear() + assert _probe_existing_chat("127.0.0.1", server.server_port, + goal_subagent_configuration_enabled=False) == "matching" + assert paths == ["/api/chat/capabilities"] + finally: + server.shutdown() + thread.join(5) + server.server_close() + + @pytest.mark.parametrize( ("capability", "requested"), [(None, True), ("preview_locked", False)], diff --git a/tests/test_support_control_update_ownership.py b/tests/test_support_control_update_ownership.py index 20d3f5490d..cdce0bad38 100644 --- a/tests/test_support_control_update_ownership.py +++ b/tests/test_support_control_update_ownership.py @@ -12,7 +12,11 @@ import argparse import re +import json from pathlib import Path +from unittest import mock + +import pytest from loopx.cli_commands import support_control from loopx.cli_commands import support_control_update as update_module @@ -76,3 +80,31 @@ def test_dispatch_ignores_other_commands() -> None: ) is None ) + + +def test_activation_has_no_registry_or_installation_dependency(monkeypatch, capsys): + from loopx.cli import main + payload = {"schema_version": "loopx_runtime_service_activation_v1", "ok": True, + "service": "chat", "status": "restart_requested", "readiness_verified": False} + monkeypatch.delenv("LOOPX_REGISTRY", raising=False) + with (mock.patch("loopx.cli_runtime.select_default_runtime_root", side_effect=AssertionError("no default store")), + mock.patch("loopx.runtime_activation.activate_runtime_services", return_value=payload) as activate, + mock.patch.object(update_module, "build_update_plan", side_effect=AssertionError("no install"))): + assert main(["--format", "json", "update", "activate", "--service", "chat", "--port", "18767"]) == 0 + activate.assert_called_once_with(service="chat", port=18767) + assert json.loads(capsys.readouterr().out) == payload + + +@pytest.mark.parametrize("args", [ + ["activate", "--dry-run"], ["activate", "--ref", "main"], + ["activate", "--background"], ["activate", "--port", "18767"], + ["plan", "--service", "chat"], +]) +def test_activation_flags_cannot_change_update_intent(args, capsys): + from loopx.cli import main + with (mock.patch("loopx.runtime_activation.activate_runtime_services") as activate, + mock.patch.object(update_module, "build_update_plan") as plan): + assert main(["--registry", "/nonexistent-registry", "--format", "json", "update", *args]) == 1 + assert json.loads(capsys.readouterr().out)["ok"] is False + activate.assert_not_called() + plan.assert_not_called()