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 ? ( -

{checkData.error}

+

+ {`${t("checkRemeFailed")}: ${checkData.error}`} +

) : null} {checkData ? (
@@ -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")}