Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
33 changes: 32 additions & 1 deletion apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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();
Expand Down
99 changes: 91 additions & 8 deletions apps/desktop/loopx-control-plane/src-tauri/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}

Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -694,6 +713,37 @@ fn terminate_verified_listener(
}
}

#[cfg(any(target_os = "macos", test))]
fn managed_chat_activation_result(
output: Option<std::process::Output>,
) -> Result<(), ServiceError> {
let payload = output
.as_ref()
.and_then(|result| serde_json::from_slice::<serde_json::Value>(&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<Vec<ListenerProcess>, ServiceError> {
let selector = format!("-iTCP:{port}");
Expand Down Expand Up @@ -1216,33 +1266,54 @@ 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<String, Probe> {
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
.take(MAX_PROBE_RESPONSE_BYTES + 1)
.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(
Expand All @@ -1266,6 +1337,18 @@ fn classify_response(
let Ok(payload) = serde_json::from_str::<serde_json::Value>(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()
Expand Down
Loading
Loading