fix false missing Hermes CLI detection after deployment
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
2026-10-24 20:59:08 +08:00
parent 05a6fbfbae
commit a54913efd2
14 changed files with 317 additions and 41 deletions
+17 -9
View File
@@ -144,30 +144,38 @@ export async function checkHealth(): Promise<HealthResponse> {
export interface HermesCliStatus {
available: boolean
bin?: string
reason?: 'ready' | 'missing' | 'permission_denied' | 'timeout' | 'failed'
}
let cliProbePromise: Promise<boolean> | null = null
let cliProbePromise: Promise<HermesCliStatus> | null = null
let refreshCliProbe = false
async function probeHermesCli(): Promise<boolean> {
async function probeHermesCli(refresh: boolean): Promise<HermesCliStatus> {
try {
const res = await request<HermesCliStatus>('/api/hermes/cli-status')
return res.available !== false
return await request<HermesCliStatus>(`/api/hermes/cli-status${refresh ? '?refresh=1' : ''}`)
} catch {
// Fail open: probe errors (older servers, mocked backends) must not block the app.
return true
// Probe transport errors must not block access to the rest of the application.
return { available: true }
}
}
/** Cached per page load; the router guard probes once before showing the app. */
export function checkHermesCliAvailable(): Promise<boolean> {
export function getHermesCliStatus(): Promise<HermesCliStatus> {
if (!cliProbePromise) {
cliProbePromise = probeHermesCli()
cliProbePromise = probeHermesCli(refreshCliProbe)
refreshCliProbe = false
}
return cliProbePromise
}
/** A slow startup is not evidence that Hermes needs installing. */
export async function checkHermesCliAvailable(): Promise<boolean> {
const status = await getHermesCliStatus()
return status.available !== false || status.reason === 'timeout'
}
export function resetHermesCliProbe(): void {
cliProbePromise = null
refreshCliProbe = true
}
export async function triggerUpdate(): Promise<{ success: boolean; message: string }> {
+6
View File
@@ -53,6 +53,12 @@ export default {
// hermes CLI missing guide
hermesMissing: {
repairHint: 'After fixing the executable or runtime, use Re-check below.',
permissionTitle: 'Hermes CLI cannot be executed',
permissionIntro: 'The service cannot execute Hermes or its Python interpreter. Check executable permissions and directory access for the service user.',
failedTitle: 'Hermes CLI failed to start',
failedIntro: 'Hermes was found but its startup check failed. Check the service logs and the Hermes Python environment.',
title: 'Hermes CLI not detected',
intro: 'hermes-ui no longer bundles the hermes CLI. Install it on the machine running this service first:',
installCmd: 'pip install hermes-agent',
+6
View File
@@ -53,6 +53,12 @@ export default {
// hermes CLI 缺失引导
hermesMissing: {
repairHint: '修复可执行文件或运行环境后,点击下方按钮重新检测。',
permissionTitle: 'Hermes CLI 执行权限不足',
permissionIntro: '服务无法执行 Hermes 或其 Python 解释器,请检查服务用户的文件执行权限和目录访问权限。',
failedTitle: 'Hermes CLI 启动失败',
failedIntro: '已找到 Hermes,但启动检测失败。请检查服务日志和 Hermes 的 Python 运行环境。',
title: '未检测到 hermes CLI',
intro: 'hermes-ui 不再内置 hermes CLI,请先在运行本服务的机器上安装:',
installCmd: 'pip install hermes-agent',
@@ -1,19 +1,24 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { checkHermesCliAvailable, resetHermesCliProbe } from "@/api/hermes/system";
import { getHermesCliStatus, resetHermesCliProbe, type HermesCliStatus } from "@/api/hermes/system";
const { t } = useI18n();
const router = useRouter();
const rechecking = ref(false);
const status = ref<HermesCliStatus | null>(null);
const issue = computed(() => status.value?.reason === 'permission_denied' ? 'permission' : status.value?.reason === 'failed' ? 'failed' : 'missing');
const title = computed(() => t(issue.value === 'missing' ? 'hermesMissing.title' : `hermesMissing.${issue.value}Title`));
const intro = computed(() => t(issue.value === 'missing' ? 'hermesMissing.intro' : `hermesMissing.${issue.value}Intro`));
onMounted(async () => { status.value = await getHermesCliStatus(); });
async function handleRetry() {
rechecking.value = true;
try {
resetHermesCliProbe();
const available = await checkHermesCliAvailable();
if (available) {
status.value = await getHermesCliStatus();
if (status.value.available || status.value.reason === 'timeout') {
router.replace("/hermes/chat");
}
} finally {
@@ -27,17 +32,18 @@ async function handleRetry() {
<div class="hermes-missing-card">
<header class="hermes-missing-hd">
<div class="hermes-missing-mark" aria-hidden="true">⚠</div>
<h1 class="hermes-missing-title">{{ t("hermesMissing.title") }}</h1>
<p class="hermes-missing-desc">{{ t("hermesMissing.intro") }}</p>
<h1 class="hermes-missing-title">{{ title }}</h1>
<p class="hermes-missing-desc">{{ intro }}</p>
</header>
<div class="hermes-missing-bd">
<pre class="hermes-missing-term"><code>{{ t("hermesMissing.installCmd") }}</code></pre>
<pre v-if="issue === 'missing'" class="hermes-missing-term"><code>{{ t("hermesMissing.installCmd") }}</code></pre>
<pre v-else-if="status?.bin" class="hermes-missing-term"><code>{{ status.bin }}</code></pre>
<p class="hermes-missing-desc">{{ t("hermesMissing.envIntro") }}</p>
<pre class="hermes-missing-term"><code>{{ t("hermesMissing.envCmd") }}</code></pre>
<p class="hermes-missing-hint">{{ t("hermesMissing.restartHint") }}</p>
<p class="hermes-missing-hint">{{ t(issue === 'missing' ? "hermesMissing.restartHint" : "hermesMissing.repairHint") }}</p>
<button class="hermes-missing-btn" :disabled="rechecking" @click="handleRetry">
{{ rechecking ? "..." : t("hermesMissing.retry") }}
+22 -12
View File
@@ -4,7 +4,9 @@ import * as hermesCli from '../services/hermes/hermes-cli'
import { getAgentBridgeManager } from '../services/hermes/agent-bridge/manager'
import { redactAgentBridgeError } from '../services/hermes/agent-bridge/redact'
import { isDockerContainer } from '../services/runtime-environment'
import { isHermesCliAvailable, resolveHermesBin } from '../services/hermes/hermes-process'
import { probeHermesCli, resolveHermesBin, type HermesCliProbeResult } from '../services/hermes/hermes-process'
import { logger } from '../services/logger'
declare const __APP_VERSION__: string
@@ -209,19 +211,27 @@ export async function healthCheck(ctx: any) {
}
}
let cachedCliStatus: { value: boolean; expiresAt: number } | null = null
let cachedCliStatus: { bin: string; value: HermesCliProbeResult; expiresAt: number } | null = null
let pendingCliStatus: { bin: string; promise: Promise<HermesCliProbeResult> } | null = null
const CLI_STATUS_CACHE_TTL_MS = 30_000
const CLI_FAILURE_CACHE_TTL_MS = 5_000
/**
* Whether the user-installed hermes CLI can be resolved and executed.
* hermes-ui no longer bundles the CLI, so the web UI probes this
* (public, pre-login) to show an install guide when it is missing.
*/
/** Public installation probe. Slow startup is distinct from a missing executable. */
export async function hermesCliStatus(ctx: any) {
const now = Date.now()
if (!cachedCliStatus || cachedCliStatus.expiresAt <= now) {
const available = await isHermesCliAvailable()
cachedCliStatus = { value: available, expiresAt: now + CLI_STATUS_CACHE_TTL_MS }
const bin = resolveHermesBin()
if (ctx.query?.refresh !== '1' && cachedCliStatus?.bin === bin && cachedCliStatus.expiresAt > Date.now()) {
ctx.body = { ...cachedCliStatus.value, bin }
return
}
ctx.body = { available: cachedCliStatus.value, bin: resolveHermesBin() }
if (!pendingCliStatus || pendingCliStatus.bin !== bin) {
const promise = probeHermesCli(bin).then(value => {
cachedCliStatus = { bin, value, expiresAt: Date.now() + (value.available ? CLI_STATUS_CACHE_TTL_MS : CLI_FAILURE_CACHE_TTL_MS) }
if (!value.available) logger.warn({ bin, reason: value.reason }, 'Hermes CLI availability probe failed')
return value
}).finally(() => {
if (pendingCliStatus?.promise === promise) pendingCliStatus = null
})
pendingCliStatus = { bin, promise }
}
ctx.body = { ...await pendingCliStatus.promise, bin }
}
@@ -70,20 +70,37 @@ export function execHermes(args: readonly string[], options?: ExecFileOptions) {
return execHermesWithBin(resolveHermesBin(), args, options)
}
/**
* Probe whether the user-installed hermes CLI is reachable.
* hermes-ui no longer bundles the CLI, so startup/UI can guide the
* user to install it instead of failing on every spawn.
*/
export async function isHermesCliAvailable(hermesBin = resolveHermesBin()): Promise<boolean> {
export type HermesCliProbeReason = 'ready' | 'missing' | 'permission_denied' | 'timeout' | 'failed'
export interface HermesCliProbeResult {
available: boolean
reason: HermesCliProbeReason
}
/** Use local help: --version may synchronously check upstream updates. */
export async function probeHermesCli(hermesBin = resolveHermesBin()): Promise<HermesCliProbeResult> {
try {
await execHermesWithBin(hermesBin, ['--version'], { timeout: 5000 })
return true
} catch {
return false
await execHermesWithBin(hermesBin, ['--help'], { timeout: 15_000 })
return { available: true, reason: 'ready' }
} catch (error: any) {
const code = error?.code
if (code === 'ENOENT' || code === 'ENOTDIR' || code === 127) {
return { available: false, reason: 'missing' }
}
if (code === 'EACCES' || code === 'EPERM' || code === 126) {
return { available: false, reason: 'permission_denied' }
}
if (code === 'ETIMEDOUT' || (error?.killed && error?.signal === 'SIGTERM')) {
return { available: false, reason: 'timeout' }
}
return { available: false, reason: 'failed' }
}
}
export async function isHermesCliAvailable(hermesBin = resolveHermesBin()): Promise<boolean> {
return (await probeHermesCli(hermesBin)).available
}
export function spawnHermesWithBin(
hermesBin: string,
args: readonly string[],