From 603ea41fbaad3fc7a1a287d154e4563f34353656 Mon Sep 17 00:00:00 2001
From: LiangshouX
Date: Thu, 29 Oct 2026 00:21:19 +0800
Subject: [PATCH] fix(dsh): finish the environment review follow-ups
- match escaped wrapper command lines when proving service ownership
- reset aborted LLM probes and consume stopped/conflict start signals
- abort start polling on endpoint switch and guard stale service state
- force --force-reinstall for Reinstall; localize start/stop reasons
- validate rendered RPC fields, prune install logs and polish UI states
---
integrations/dsh/docs/environment.md | 9 +-
integrations/dsh/docs/environment_ZH.md | 8 +-
.../dsh/src/environment/client/dependency.tsx | 22 +-
.../dsh/src/environment/client/locales.ts | 43 +++
.../dsh/src/environment/client/python.tsx | 16 +-
.../dsh/src/environment/client/rpc.ts | 40 ++-
.../dsh/src/environment/client/service.tsx | 120 +++++--
.../dsh/src/environment/client/tab.tsx | 48 ++-
.../dsh/src/environment/client/workspace.tsx | 4 +
.../dsh/src/environment/host/gateway.ts | 8 +-
.../dsh/src/environment/host/install.ts | 11 +-
integrations/dsh/src/environment/host/log.ts | 42 ++-
.../dsh/src/environment/host/manager.ts | 9 +
.../dsh/src/environment/host/service.ts | 64 +++-
integrations/dsh/src/environment/types.ts | 22 ++
integrations/dsh/tests/env-reme.test.mjs | 6 +
integrations/dsh/tests/env-runner.test.mjs | 19 +-
integrations/dsh/tests/env-service.test.mjs | 13 +
integrations/dsh/tests/env-tab.test.mjs | 307 +++++++++++++++++-
19 files changed, 736 insertions(+), 75 deletions(-)
diff --git a/integrations/dsh/docs/environment.md b/integrations/dsh/docs/environment.md
index bb81c112..82b0a077 100644
--- a/integrations/dsh/docs/environment.md
+++ b/integrations/dsh/docs/environment.md
@@ -59,9 +59,10 @@ The check asks the selected interpreter three questions and reports them as metr
- `[core]` extras: whether `import agentscope` works. Without it, `auto_memory` and `auto_dream` are unavailable.
- `reme` CLI: the console script beside the interpreter, or the Python wrapper fallback used at start time.
-**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with an upgrade and a package
-index (PyPI, Tsinghua, Aliyun). Large wheels are downloaded on first use; expect 5–20 minutes depending on the
-network.
+**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with a package index (PyPI,
+Tsinghua, Aliyun). **Upgrade** adds `-U`; **Reinstall** adds `--force-reinstall`, because pip otherwise reports a
+present-but-broken install as "already satisfied". Large wheels are downloaded on first use; expect 5–20 minutes
+depending on the network.
The installation is a Host-owned job:
@@ -71,7 +72,7 @@ The installation is a Host-owned job:
- **Cancel** stops pip and its build children, and the interpreter stays locked until the process has actually
exited.
- The log is written to `$DSH_HOME/reme-memory/install-.log` and its tail is shown in place; install and
- service logs are rotated to `.log.1` once they pass 5 MB.
+ service logs are rotated to `.log.1` once they pass 5 MB, and only the newest 20 install logs are kept.
- When the plugin unloads, running jobs are stopped.
## Workspace `.env`
diff --git a/integrations/dsh/docs/environment_ZH.md b/integrations/dsh/docs/environment_ZH.md
index ef4ac4f3..3124d97b 100644
--- a/integrations/dsh/docs/environment_ZH.md
+++ b/integrations/dsh/docs/environment_ZH.md
@@ -52,15 +52,17 @@ ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列
- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。
- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。
-**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加升级与软件源(PyPI、清华、阿里云)。首次使用需要
-下载较大的依赖,视网络情况通常 5–20 分钟。
+**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加软件源(PyPI、清华、阿里云)。**升级**追加
+`-U`;**重装**追加 `--force-reinstall`,否则 pip 会把“存在但已损坏”的安装报成 “already satisfied”。首次使用需要下载较大的
+依赖,视网络情况通常 5–20 分钟。
安装是 Host 侧的任务:
- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。
- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。
- **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。
-- 日志写入 `$DSH_HOME/reme-memory/install-.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`。
+- 日志写入 `$DSH_HOME/reme-memory/install-.log`,页面直接显示尾部;安装与服务日志超过 5 MB 会轮转为 `.log.1`,
+ 并且目录只保留最近 20 个安装日志。
- 插件卸载时,仍在运行的安装会被停止。
## Workspace `.env`
diff --git a/integrations/dsh/src/environment/client/dependency.tsx b/integrations/dsh/src/environment/client/dependency.tsx
index 394c621c..427d9682 100644
--- a/integrations/dsh/src/environment/client/dependency.tsx
+++ b/integrations/dsh/src/environment/client/dependency.tsx
@@ -31,7 +31,11 @@ export interface DependencyState {
/** Wall clock tick while an installation runs, for the elapsed label. */
now: number;
recheck(): void;
- start(upgrade: boolean, indexUrl?: string): Promise;
+ start(
+ upgrade: boolean,
+ indexUrl?: string,
+ forceReinstall?: boolean,
+ ): Promise;
cancel(): Promise;
dismiss(): void;
}
@@ -177,7 +181,7 @@ export function useDependency(
}, []);
const start = useCallback(
- async (upgrade: boolean, indexUrl?: string) => {
+ async (upgrade: boolean, indexUrl?: string, forceReinstall?: boolean) => {
if (!pythonPath || install.phase === "running") return;
setInstall({ phase: "idle" });
try {
@@ -186,6 +190,7 @@ export function useDependency(
pythonPath,
upgrade,
...(indexUrl ? { indexUrl } : {}),
+ ...(forceReinstall ? { forceReinstall } : {}),
}),
);
setInstall(
@@ -256,7 +261,7 @@ export function DependencySection({
: checkData.versionOk && checkData.hasCore
? { tone: "ok" as const, text: t("depReady") }
: checkData.versionOk
- ? { tone: "progress" as const, text: t("coreMissing") }
+ ? { tone: "progress" as const, text: t("coreMissingBadge") }
: { tone: "progress" as const, text: t("depUpgrade") };
return (
@@ -302,7 +307,9 @@ export function DependencySection({
) : null}
{checkData?.error ? (
-
@@ -386,11 +393,16 @@ export function DependencySection({
className="reme-settings-save"
onClick={() => {
setConfirming(false);
+ // Fresh install: no upgrade. Outdated: upgrade. Current
+ // but suspect (Reinstall): force pip to reinstall.
+ const reinstall =
+ checkData.installed && checkData.versionOk;
// The mirror choice reaches pip as `--index-url`; without
// it the selector would be decorative.
void dependency.start(
- !checkData.versionOk,
+ checkData.installed && !checkData.versionOk,
mirror || undefined,
+ reinstall,
);
}}
>
diff --git a/integrations/dsh/src/environment/client/locales.ts b/integrations/dsh/src/environment/client/locales.ts
index f394759e..1cf7e00b 100644
--- a/integrations/dsh/src/environment/client/locales.ts
+++ b/integrations/dsh/src/environment/client/locales.ts
@@ -42,6 +42,7 @@ export const envEn = {
upgradeRecommended: "upgrade recommended",
coreOk: "Available",
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
+ coreMissingBadge: "core missing",
cliFallback: "python wrapper fallback",
installButton: "Install reme-ai[core]",
upgradeButton: "Upgrade reme-ai",
@@ -97,17 +98,41 @@ export const envEn = {
"Start or stop the local ReMe service. The service keeps running after DSH closes.",
serviceBadgeStarting: "Starting…",
serviceBadgeConflict: "Port occupied",
+ serviceBadgeUnhealthy: "Not healthy",
serviceBadgeStopped: "Stopped",
serviceRunning: "Running",
startService: "Start service",
stopService: "Stop service",
startTimeout: "The service did not become healthy within 120 seconds",
+ startFailedStopped:
+ "The service exited before it became healthy. Open the log below.",
+ startErrorOtherEndpoint:
+ "A service start for another endpoint is still in progress.",
+ startErrorConflict: "Another process is already listening on this endpoint.",
+ startErrorOtherEndpointRunning:
+ "A service for another endpoint is still running; stop it there first.",
+ startErrorPreviousNotStopped:
+ "The previous service could not be stopped; check the log.",
+ startErrorNoPython: "Select a Python interpreter first.",
+ startErrorNotInstalled:
+ "reme-ai is not installed in the selected interpreter.",
+ startErrorVersionTooOld:
+ "The installed reme-ai is too old; upgrade it first.",
+ startErrorFailed: "The service could not be started; check the log.",
+ stopNoRecord: "No service was started by this plugin.",
+ stopForeign: "The recorded service does not belong to this endpoint.",
+ stopUnknownIdentity:
+ "The recorded process could not be verified, so it was left running.",
+ stopNotExited:
+ "The service did not exit; its record was kept so you can retry.",
stopConfirm:
"Stop the ReMe service? Automatic memory in flight may need a retry.",
foreignNote:
"This service was started outside DSH; its state is shown but it is not managed here.",
conflictNote:
"Another program occupies this port. Change the service URL port in Memory settings.",
+ conflictManagedNote:
+ "This plugin's service holds the port but is not reporting healthy. Stop it to recover.",
viewLogs: "View log",
logEmpty: "The service log is empty.",
autoStartLabel: "Start the service automatically with DSH",
@@ -192,6 +217,7 @@ export const envZh: typeof envEn = {
upgradeRecommended: "建议升级",
coreOk: "可用",
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
+ coreMissingBadge: "缺少 core",
cliFallback: "回退到 python 包装",
installButton: "安装 reme-ai[core]",
upgradeButton: "升级 reme-ai",
@@ -244,14 +270,31 @@ export const envZh: typeof envEn = {
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
serviceBadgeStarting: "启动中…",
serviceBadgeConflict: "端口被占用",
+ serviceBadgeUnhealthy: "未就绪",
serviceBadgeStopped: "已停止",
serviceRunning: "运行中",
startService: "启动服务",
stopService: "停止服务",
startTimeout: "服务在 120 秒内未就绪",
+ startFailedStopped: "服务在就绪前已退出,请查看下方日志。",
+ startErrorOtherEndpoint: "另一个服务地址的启动仍在进行中。",
+ startErrorConflict: "该端口已被其他程序占用。",
+ startErrorOtherEndpointRunning:
+ "另一个服务地址的服务仍在运行,请先到那个地址停止它。",
+ startErrorPreviousNotStopped: "上一次服务未能停止,请查看日志。",
+ startErrorNoPython: "请先选择一个 Python 解释器。",
+ startErrorNotInstalled: "所选解释器尚未安装 reme-ai。",
+ startErrorVersionTooOld: "已安装的 reme-ai 版本过低,请先升级。",
+ startErrorFailed: "服务未能启动,请查看日志。",
+ stopNoRecord: "本插件没有启动过该服务。",
+ stopForeign: "记录中的服务不属于当前服务地址。",
+ stopUnknownIdentity: "无法验证记录中的进程身份,已保持其运行。",
+ stopNotExited: "服务未退出;已保留记录,可稍后重试。",
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
+ conflictManagedNote:
+ "本插件的服务占用了端口但未通过健康检查;停止它后即可恢复。",
viewLogs: "查看日志",
logEmpty: "服务日志为空。",
autoStartLabel: "随 DSH 启动时自动拉起服务",
diff --git a/integrations/dsh/src/environment/client/python.tsx b/integrations/dsh/src/environment/client/python.tsx
index afbb4fac..ba2a6314 100644
--- a/integrations/dsh/src/environment/client/python.tsx
+++ b/integrations/dsh/src/environment/client/python.tsx
@@ -67,18 +67,28 @@ export function usePython(
setDetection({ phase: "ready", data: parseDetection(value) });
} catch (error) {
if (!controller.signal.aborted)
- setDetection({ phase: "error", error: safeError(error) });
+ setDetection((current) => ({
+ phase: "error",
+ error: safeError(error),
+ ...(current.data ? { data: current.data } : {}),
+ }));
}
})();
return () => controller.abort();
}, [rpc, tick, enabled]);
- const detected = detection.phase === "ready" ? detection.data : undefined;
+ // A refresh keeps the last detection data, so the selected interpreter (and
+ // the install/progress panels keyed off it) does not flash away while the
+ // new probe is in flight.
+ const detected = detection.data;
const suggested = detected?.suggested ?? "";
const selectedPath = pythonPath || suggested;
const refresh = useCallback(() => {
- setDetection({ phase: "loading" });
+ setDetection((current) => ({
+ phase: "loading",
+ ...(current.data ? { data: current.data } : {}),
+ }));
setTick((current) => current + 1);
}, []);
diff --git a/integrations/dsh/src/environment/client/rpc.ts b/integrations/dsh/src/environment/client/rpc.ts
index a175137b..12cc5f0e 100644
--- a/integrations/dsh/src/environment/client/rpc.ts
+++ b/integrations/dsh/src/environment/client/rpc.ts
@@ -77,6 +77,14 @@ function booleanField(
return value;
}
+function optionalStringField(
+ body: Record
,
+ key: string,
+ what: string,
+): void {
+ if (body[key] !== undefined && typeof body[key] !== "string") fail(what);
+}
+
function arrayField(
body: Record,
key: string,
@@ -105,7 +113,29 @@ export function parseDetection(value: unknown): PythonDetection {
stringField(env, "exe", what);
stringField(env, "version", what);
booleanField(env, "ok", what);
- record(env.origin, what);
+ const origin = record(env.origin, what);
+ const kind = stringField(origin, "kind", what);
+ if (
+ ![
+ "conda",
+ "python.org",
+ "windows-store",
+ "venv",
+ "system",
+ "unknown",
+ ].includes(kind)
+ )
+ fail(what);
+ if (kind === "conda") {
+ stringField(origin, "envName", what);
+ stringField(origin, "root", what);
+ }
+ if (kind === "venv") stringField(origin, "baseExe", what);
+ if (env.reme !== undefined) {
+ const reme = record(env.reme, what);
+ booleanField(reme, "installed", what);
+ stringField(reme, "version", what);
+ }
}
return value as PythonDetection;
}
@@ -118,6 +148,7 @@ export function parseRemeCheck(value: unknown): RemeCheck {
booleanField(body, "hasCore", what);
stringField(body, "version", what);
stringField(body, "cliPath", what);
+ stringField(body, "error", what);
return value as unknown as RemeCheck;
}
@@ -130,6 +161,8 @@ export function parseInstallStatus(value: unknown): InstallStatus | null {
stringField(body, "pythonPath", what);
stringField(body, "startedAt", what);
stringField(body, "logPath", what);
+ stringField(body, "logTail", what);
+ if (body.cancelling !== undefined) booleanField(body, "cancelling", what);
const phase = stringField(body, "phase", what);
if (!["running", "succeeded", "failed", "cancelled"].includes(phase))
fail(what);
@@ -190,6 +223,8 @@ export function parseServiceStart(value: unknown): ServiceStartResult {
stringField(body, "command", what);
stringField(body, "logPath", what);
stringField(body, "startedAt", what);
+ optionalStringField(body, "error", what);
+ optionalStringField(body, "code", what);
return value as unknown as ServiceStartResult;
}
@@ -197,12 +232,15 @@ export interface ServiceStopView {
stopped: boolean;
pid?: number;
reason?: string;
+ code?: string;
}
export function parseServiceStop(value: unknown): ServiceStopView {
const what = "service stop";
const body = record(value, what);
booleanField(body, "stopped", what);
+ optionalStringField(body, "reason", what);
+ optionalStringField(body, "code", what);
return value as ServiceStopView;
}
diff --git a/integrations/dsh/src/environment/client/service.tsx b/integrations/dsh/src/environment/client/service.tsx
index cafe02d9..22dab237 100644
--- a/integrations/dsh/src/environment/client/service.tsx
+++ b/integrations/dsh/src/environment/client/service.tsx
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { StatusRpc } from "../../client/status-page.js";
import { safeError } from "../../endpoint.js";
-import type { ServiceState } from "../types.js";
-import type { EnvTranslator } from "./locales.js";
-import { fill } from "./locales.js";
+import type { ServiceStartResult, ServiceState } from "../types.js";
+import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
import { StatusBadge } from "./presentation.js";
import {
envCall,
@@ -21,6 +20,38 @@ import {
*/
const START_DEADLINE_MS = 120_000;
+/** Host start errors carry a stable code; older/unknown codes fall back. */
+const START_ERROR_KEYS: Record = {
+ "other-endpoint-starting": "startErrorOtherEndpoint",
+ conflict: "startErrorConflict",
+ "other-endpoint-running": "startErrorOtherEndpointRunning",
+ "previous-not-stopped": "startErrorPreviousNotStopped",
+ "no-python": "startErrorNoPython",
+ "not-installed": "startErrorNotInstalled",
+ "version-too-old": "startErrorVersionTooOld",
+ "start-failed": "startErrorFailed",
+};
+
+function startErrorText(t: EnvTranslator, result: ServiceStartResult): string {
+ const key = result.code ? START_ERROR_KEYS[result.code] : undefined;
+ return key ? t(key) : result.error || t("serviceError");
+}
+
+const STOP_REASON_KEYS: Record = {
+ "no-record": "stopNoRecord",
+ foreign: "stopForeign",
+ unverified: "stopUnknownIdentity",
+ "not-exited": "stopNotExited",
+};
+
+function stopReasonText(
+ t: EnvTranslator,
+ result: { code?: string; reason?: string },
+): string {
+ const key = result.code ? STOP_REASON_KEYS[result.code] : undefined;
+ return key ? t(key) : result.reason || t("serviceError");
+}
+
export interface ServiceViewState {
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
data?: ServiceState;
@@ -75,9 +106,15 @@ export function useService(
const startController = useRef(undefined);
// A new endpoint has no known phase yet; keeping the previous endpoint's
- // "running + old pid/version" on screen would mislabel the new one.
+ // "running + old pid/version" on screen would mislabel the new one. A start
+ // poll for the old endpoint must stop before it can paint that old data.
useEffect(() => {
+ startController.current?.abort();
+ // Resetting the view when the endpoint prop changes is intentional; the
+ // hook has no key, so this is the reset path.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setState({ phase: "checking" });
+ setStopError("");
}, [endpoint]);
useEffect(() => () => startController.current?.abort(), []);
@@ -96,18 +133,19 @@ export function useService(
),
);
if (controller.signal.aborted) return;
- setState((current) =>
- current.phase === "starting"
- ? current
- : { phase: next.phase, data: next },
- );
+ // Recheck must not be swallowed while a start is in flight: the
+ // response belongs to the endpoint on screen. `stopped` with a live pid
+ // is the Host's "launch still booting" signal.
+ setState({
+ phase:
+ next.phase === "stopped" && next.pid !== undefined
+ ? "starting"
+ : next.phase,
+ data: next,
+ });
} catch (error) {
if (controller.signal.aborted) return;
- setState((current) =>
- current.phase === "starting"
- ? current
- : { phase: "error", error: safeError(error) },
- );
+ setState({ phase: "error", error: safeError(error) });
}
})();
return () => controller.abort();
@@ -134,7 +172,7 @@ export function useService(
);
if (controller.signal.aborted) return;
if (started.error) {
- setState({ phase: "error", error: started.error });
+ setState({ phase: "error", error: startErrorText(t, started) });
return;
}
const deadline = Date.now() + START_DEADLINE_MS;
@@ -154,10 +192,23 @@ export function useService(
controller.signal,
),
);
+ if (controller.signal.aborted) return;
if (status.phase === "running") {
setState({ phase: "running", data: status });
return;
}
+ if (status.phase === "conflict") {
+ // Someone (possibly our own booting service) owns the port.
+ setState({ phase: "conflict", data: status });
+ return;
+ }
+ if (status.phase === "stopped" && status.pid === undefined) {
+ // The Host already dropped the start marker: the launch died before
+ // it ever listened, so waiting out the 120 s deadline is wrong.
+ setState({ phase: "error", error: t("startFailedStopped") });
+ return;
+ }
+ // stopped with a live pid: the process is still booting.
} catch {
// Keep polling: a starting service answers once it is listening.
}
@@ -175,7 +226,7 @@ export function useService(
const result = parseServiceStop(
await envCall(rpc, "serviceStop", { endpoint }),
);
- setStopError(result.stopped ? "" : result.reason || t("serviceError"));
+ setStopError(result.stopped ? "" : stopReasonText(t, result));
refresh();
} catch (error) {
setState({ phase: "error", error: safeError(error) });
@@ -276,6 +327,7 @@ export function ServiceSection({
remeInstalled,
autoStart,
writable,
+ current,
onAutoStart,
}: {
t: EnvTranslator;
@@ -285,6 +337,8 @@ export function ServiceSection({
remeInstalled: boolean;
autoStart: boolean;
writable: boolean;
+ /** False while the state on screen belongs to another endpoint. */
+ current: boolean;
onAutoStart(checked: boolean): void;
}): JSX.Element {
const { state, stopping, stopError, log } = control;
@@ -293,8 +347,9 @@ export function ServiceSection({
// offer the same Stop affordance instead of leaving the tab without a way
// out other than editing the record by hand.
const canStop =
- state.phase === "running" ||
- (state.phase === "conflict" && state.data?.managed === true);
+ current &&
+ (state.phase === "running" ||
+ (state.phase === "conflict" && state.data?.managed === true));
return (
@@ -307,10 +362,14 @@ export function ServiceSection({
{t("serviceHint")}