Prechádzať zdrojové kódy

为工具桥增加本机托管 Dashboard 重启功能

Samien 1 mesiac pred
rodič
commit
9d96522

+ 3 - 2
docs/local-vm.md

@@ -42,10 +42,11 @@
 - 每个 Profile 单独显示安装版本、实际加载版本和状态。缺少后台入口时,先安装或更新默认 Profile,再安装命名 Profile。
 - “安装并启用”只启用选中的工具桥及其工具集,保留其他插件、禁用项和模型配置。每次安装保留原插件与配置备份,界面可查看备份位置。
 - 检查会比较插件文件指纹与 Hermes 启动时加载的指纹;同版本修复后未重启也会显示“待重启”,不会仅凭安装文件判断就绪。
-- 安装不会自动重启 Hermes。请在空闲时重启 Dashboard 服务(默认 9119),再点击“重新检查”。正在执行任务时暂不可安装。
+- 安装不会自动重启 Hermes。由 Yaoyao 桌面服务自己启动并持有进程的本机 Dashboard(`127.0.0.1:9119`),可在此页面点击“重启 Hermes Dashboard”,完成后自动检查各 Profile 的实际加载状态。重启影响整个 Dashboard,而非单个 Profile。
+- 正在执行任务、安装工具桥或已有重启操作时不可重启;重启期间暂不接受新任务,定时任务等待下一次检查。外部启动的 Dashboard、Docker 目录映射与远程 Hermes 仍需在所在节点重启,再点击“重新检查”。正在执行任务时暂不可安装。
 - 本机安装用于当前服务可访问的本机 Hermes。Docker 可显式映射对应 Hermes 数据目录以安装工具桥,详见 [Docker 目录映射](docker-install.md#映射-hermes-目录以安装工具桥);其他远程节点支持状态检查,需在对应节点安装。
 
-管理员 API:`GET /api/app/admin/hermes-bridge` 与 `POST /api/app/admin/hermes-bridge/install`,安装参数仅为 `profile` 和可选的 `enable`;不接受客户端传入目录、命令或 Python 路径。
+管理员 API:`GET /api/app/admin/hermes-bridge`、`POST /api/app/admin/hermes-bridge/install` 与 `POST /api/app/admin/hermes-bridge/restart`。重启接口无需参数,状态中的 `dashboard` 提供托管状态、是否可重启及原因;安装参数仅为 `profile` 和可选的 `enable`。不接受客户端指定重启目标或执行命令。
 
 需要 Runner 的 `hermes-computer-v2` 和 `workspace-memory-bind-v1` 能力,以及每个使用中的 Profile 安装工具桥 **1.2.1**,能力响应包含 `computer_runtime_version: 2`。旧插件会明确提示更新,不回退启动 Worker。
 

+ 4 - 1
src/client/api/hermesBridge.ts

@@ -1,7 +1,10 @@
 import {apiRequest} from './client'
-import type {HermesBridgeStatus,HermesBridgeInstallResult} from '@shared/hermesBridge'
+import type {HermesBridgeStatus,HermesBridgeInstallResult,HermesDashboardRestartResult} from '@shared/hermesBridge'
 
 export const getHermesBridgeStatus=()=>apiRequest<HermesBridgeStatus>('/api/app/admin/hermes-bridge',{timeoutMs:45000})
 export const installHermesBridge=(profile:string,enable=false)=>apiRequest<HermesBridgeInstallResult>('/api/app/admin/hermes-bridge/install',{
   method:'POST',body:{profile,enable},timeoutMs:60000,
 })
+export const restartHermesDashboard=()=>apiRequest<HermesDashboardRestartResult>('/api/app/admin/hermes-bridge/restart',{
+  method:'POST',body:{},timeoutMs:120000,
+})

+ 30 - 9
src/client/components/app/HermesBridgePanel.vue

@@ -1,18 +1,19 @@
 <script setup lang="ts">
 import {onBeforeUnmount,onMounted,ref} from 'vue'
-import {getHermesBridgeStatus,installHermesBridge} from '@/api/hermesBridge'
+import {getHermesBridgeStatus,installHermesBridge,restartHermesDashboard} from '@/api/hermesBridge'
 import type {HermesBridgeProfileStatus,HermesBridgeState,HermesBridgeStatus} from '@shared/hermesBridge'
 import type {Profile} from '@shared/types'
 
 const props=withDefaults(defineProps<{profiles?:Profile[]}>(),{profiles:()=>[]})
 const status=ref<HermesBridgeStatus>(),checking=ref(false),installing=ref(''),error=ref(''),notice=ref(''),backup=ref('')
 const lastInstalled=ref('')
+const restarting=ref(false)
 const labels:Record<HermesBridgeState,string>={ready:'已就绪',missing:'未安装',outdated:'需要更新','restart-required':'待重启',disabled:'未启用',unavailable:'无法确认'}
 let generation=0,disposed=false,timer:ReturnType<typeof setTimeout>|undefined
 function profileLabel(name:string){const profile=props.profiles.find(p=>p.name===name);return profile?.agentName||profile?.displayName||name}
 function actionLabel(profile:HermesBridgeProfileStatus){return profile.state==='missing'||profile.state==='disabled'?'安装并启用':profile.state==='outdated'?'更新工具桥':'重新安装'}
 async function load(){
-  if(installing.value)return
+  if(installing.value||restarting.value)return
   clearTimeout(timer)
   const current=++generation;checking.value=true;error.value=''
   try{
@@ -20,13 +21,13 @@ async function load(){
     if(disposed||current!==generation)return
     status.value=result
     if(lastInstalled.value&&result.profiles.find(p=>p.profile===lastInstalled.value)?.state==='ready')notice.value='检查完成,工具桥已就绪。'
-    if(result.installing)timer=setTimeout(()=>void load(),1500)
+    if(result.installing||result.dashboard?.restarting)timer=setTimeout(()=>void load(),1500)
   }catch(cause){if(!disposed&&current===generation)error.value=cause instanceof Error?cause.message:'无法检查工具桥状态'}
   finally{if(!disposed&&current===generation)checking.value=false}
 }
 async function install(profile:HermesBridgeProfileStatus){
-  if(installing.value||checking.value||!profile.canInstall)return
-  ++generation;installing.value=profile.profile;error.value='';notice.value='';backup.value=''
+  if(installing.value||checking.value||restarting.value||status.value?.dashboard?.restarting||!profile.canInstall)return
+  clearTimeout(timer);++generation;installing.value=profile.profile;error.value='';notice.value='';backup.value=''
   try{
     const result=await installHermesBridge(profile.profile,profile.state==='disabled'||profile.state==='missing')
     if(disposed)return
@@ -34,15 +35,29 @@ async function install(profile:HermesBridgeProfileStatus){
   }catch(cause){if(!disposed)error.value=cause instanceof Error?cause.message:'工具桥安装失败'}
   finally{if(!disposed)installing.value=''}
 }
+async function restart(){
+  if(checking.value||installing.value||restarting.value||!status.value?.dashboard?.canRestart)return
+  clearTimeout(timer);++generation;restarting.value=true;error.value='';notice.value=''
+  try{
+    const result=await restartHermesDashboard()
+    if(disposed)return
+    status.value=result.status;notice.value=result.message
+  }catch(cause){
+    if(disposed)return
+    error.value=cause instanceof Error?cause.message:'Hermes Dashboard 重启失败'
+    // Refresh capabilities after failure without hiding the failure message.
+    try{const result=await getHermesBridgeStatus();if(!disposed){status.value=result;if(result.dashboard?.restarting)timer=setTimeout(()=>void load(),1500)}}catch{}
+  }finally{if(!disposed)restarting.value=false}
+}
 onMounted(()=>void load())
 onBeforeUnmount(()=>{disposed=true;++generation;clearTimeout(timer)})
 </script>
 
 <template>
-  <section class="hermes-bridge" aria-label="工具桥插件" :aria-busy="checking || !!installing || !!status?.installing">
+  <section class="hermes-bridge" aria-label="工具桥插件" :aria-busy="checking || !!installing || !!status?.installing || restarting || !!status?.dashboard?.restarting">
     <header>
       <div><h4>工具桥插件</h4><p>连接 Bot 的电脑、技能和应用工具。默认及其他 Profile 需分别安装。</p></div>
-      <button type="button" :disabled="checking || !!installing" @click="load">{{ checking ? '检查中…' : '重新检查' }}</button>
+      <button type="button" :disabled="checking || !!installing || restarting" @click="load">{{ checking ? '检查中…' : '重新检查' }}</button>
     </header>
     <p v-if="error" class="error" role="alert">{{ error }}</p>
     <p v-if="!status && checking" class="loading" role="status">正在检查安装文件和 Hermes 加载状态…</p>
@@ -56,19 +71,25 @@ onBeforeUnmount(()=>{disposed=true;++generation;clearTimeout(timer)})
             <p>{{ profile.message }}</p>
             <small>随附版本 {{ status.bundledVersion }}<template v-if="profile.installedVersion"> · 已安装 {{ profile.installedVersion }}</template><template v-if="profile.loadedVersion"> · 已加载 {{ profile.loadedVersion }}</template></small>
           </div>
-          <button v-if="status.local" type="button" class="install" :disabled="checking || !!installing || !profile.canInstall" :aria-label="`${actionLabel(profile)}:${profileLabel(profile.profile)}`" @click="install(profile)">
+          <button v-if="status.local" type="button" class="install" :disabled="checking || !!installing || restarting || !!status.dashboard?.restarting || !profile.canInstall" :aria-label="`${actionLabel(profile)}:${profileLabel(profile.profile)}`" @click="install(profile)">
             {{ installing===profile.profile || status.installing===profile.profile ? '安装中…' : actionLabel(profile) }}
           </button>
         </article>
       </div>
+      <div v-if="status.dashboard?.managed || restarting" class="dashboard-action">
+        <p id="hermes-dashboard-restart-hint">{{ restarting ? '正在重启 Hermes Dashboard 并检查工具桥加载状态…' : status.dashboard?.message }}</p>
+        <button type="button" class="restart" aria-describedby="hermes-dashboard-restart-hint" :disabled="checking || !!installing || restarting || !status.dashboard?.canRestart" @click="restart">{{ restarting || status.dashboard?.restarting ? '重启中…' : '重启 Hermes Dashboard' }}</button>
+      </div>
     </template>
     <div v-if="notice" class="result" role="status"><p>{{ notice }}</p><details v-if="backup"><summary>查看备份位置</summary><code>{{ backup }}</code></details></div>
-    <p class="footnote">安装前会备份原插件与配置。安装完成后,请在空闲时重启 Hermes Dashboard 服务,再点击重新检查。</p>
+    <p class="footnote">安装前会备份原插件与配置。{{ status?.dashboard?.managed ? '安装完成后,可在空闲时点击重启 Hermes Dashboard,完成后自动检查。' : '安装完成后,请在 Hermes 所在节点重启 Dashboard 服务,再点击重新检查。' }}</p>
   </section>
 </template>
 
 <style scoped>
 .hermes-bridge{display:grid;gap:14px;margin-top:22px;padding-top:22px;border-top:1px solid var(--line);color:var(--text-primary);font-size:13px;line-height:1.65}
+.dashboard-action{display:flex;align-items:center;justify-content:space-between;gap:16px}.dashboard-action p{min-width:0}.restart{color:var(--accent);white-space:normal}
+@media(max-width:600px){.dashboard-action{align-items:stretch;flex-direction:column;gap:12px}.restart{align-self:flex-start;max-width:100%}}
 header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}h4{margin:0 0 5px;font-size:15px}p{margin:0;color:var(--text-secondary)}button{flex-shrink:0;min-height:44px;padding:8px 14px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}button:hover:not(:disabled){background:var(--surface-soft)}button:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}button:disabled{opacity:.55;cursor:default}.profiles{display:grid;border:1px solid var(--line);border-radius:12px;overflow:hidden}.profile{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px;background:var(--surface-soft)}.profile+.profile{border-top:1px solid var(--line)}.profile-detail{min-width:0;display:grid;gap:5px;overflow-wrap:anywhere}.profile-heading{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.profile-heading strong{font-size:14px}.badge{border:1px solid var(--line);border-radius:6px;padding:1px 7px;font-size:12px;color:var(--text-secondary);background:var(--surface)}.badge.ready{color:var(--success,var(--accent))}.badge.outdated,.badge.restart-required{color:var(--accent)}small,.footnote{font-size:12px;color:var(--text-secondary)}.error{color:var(--danger)}.result{padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft)}.result details{margin-top:8px}.result summary{cursor:pointer}.result code{display:block;margin-top:8px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}.install{color:var(--accent)}
 @media(max-width:600px){header{flex-wrap:wrap}.profile{align-items:stretch;flex-direction:column;gap:12px}.install{align-self:flex-start}header button{margin-left:auto}}
 </style>

+ 21 - 1
src/server/app.ts

@@ -66,8 +66,10 @@ import {SharedComputers} from './sharedComputers.js'
 import {ComputerControlService} from './computerControls.js'
 import { RunnerHub } from './runnerHub.js'
 import { HermesBridgeManager } from './hermesBridge.js'
+import type { DashboardSupervisor } from './dashboardSupervisor.js'
 
 export interface ApplicationOptions {
+  dashboardSupervisor?: Pick<DashboardSupervisor,'canRestart'|'restart'>
   hermesBridge?: HermesBridgeManager
   config?: ServerConfig
   fetchImpl?: typeof fetch
@@ -249,7 +251,13 @@ export function createApplication(options: ApplicationOptions = {}): Application
   runners.controlAllowed=(id,runnerId)=>computerControls.allowed(id,runnerId)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0, openVikingService)
-  const hermesBridge=options.hermesBridge??new HermesBridgeManager(config,upstreamSession,{isIdle:()=>workspaceRuntime.idleForUpdate&&runners.idleForUpdate&&realtime.broker.idleForUpdate})
+  let pendingDashboardMutations=0
+  const hermesBridge=options.hermesBridge??new HermesBridgeManager(config,upstreamSession,{
+    dashboard:options.dashboardSupervisor,
+    isIdle:()=>workspaceRuntime.idleForUpdate&&runners.idleForUpdate&&realtime.broker.idleForUpdate&&desktopEnvironments.idleForUpdate,
+    isRestartIdle:()=>pendingDashboardMutations===0,
+  })
+  workspaceRuntime.assertCanSubmit=()=>hermesBridge.assertDashboardAvailable()
   const workspaceMemory = new WorkspaceMemorySynthesis(workspaceRuntime)
   workspaceMemory.start()
   const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
@@ -265,6 +273,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspaceInspector=new WorkspaceInspector(workspace,auth)
   workspaceRuntime.inspector=workspaceInspector
   const workspaceRoutines=new WorkspaceRoutines(workspace,auth,workspaceNodes,workspaceRuntime)
+  workspaceRoutines.paused=()=>hermesBridge.dashboardRestarting
   const workspacePlugins=new WorkspacePlugins(workspace,workspaceNodes,auth,workspaceRuntime,config.home,options.pluginFetch)
   workspaceRuntime.plugins=workspacePlugins
   workspaceRuntime.retireHelper=(owner,helper)=>runners.retireHelper(owner,helper)
@@ -284,6 +293,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   }, (owner, profile, sessionID) =>
     chatCache?.store.ownsSession(owner, profile, sessionID) ?? false)
   realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
+  realtime.broker.assertCanCommand=()=>hermesBridge.assertDashboardAvailable()
   realtime.transcriptsAvailable = chatCache.store.transcripts.enabled
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
     chatCache?.observe(owner, profile, storedId, frame)
@@ -367,6 +377,16 @@ export function createApplication(options: ApplicationOptions = {}): Application
     }
     await next()
   })
+  app.use(async (ctx,next)=>{
+    // Count admitted requests before async parsing/authorization. A restart must
+    // not race a request which has not yet reached the runtime's task counters.
+    const mutation=isMutation(ctx.method)&&!/^\/api\/runner\/.*\/(poll|heartbeat)$/.test(ctx.path)
+      &&ctx.path!=='/api/app/admin/hermes-bridge/restart'
+    if(!mutation){await next();return}
+    hermesBridge.assertDashboardAvailable()
+    pendingDashboardMutations++
+    try{await next()}finally{pendingDashboardMutations--}
+  })
   app.use(runners.middleware())
   app.use(desktopHosts.middleware())
   app.use(async (ctx, next) => {

+ 55 - 32
src/server/dashboardSupervisor.ts

@@ -1,4 +1,4 @@
-import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
+import { execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
 import { createConnection } from 'node:net'
 
 const DASHBOARD_PORT = 9119
@@ -17,6 +17,8 @@ export interface DashboardSupervisorOptions {
   probe?: Probe
   log?: (message: string) => void
   managed?: boolean
+  spawnManaged?: (command: string, args: string[], options: SpawnOptions) => ChildProcess
+  readyTimeoutMs?: number
 }
 
 function defaultRun(command: string): Run {
@@ -63,6 +65,9 @@ export class DashboardSupervisor {
   private owned: ChildProcess | undefined
   private stopped = false
   private readonly managed: boolean
+  private restarting = false
+  private readonly spawnManaged: NonNullable<DashboardSupervisorOptions['spawnManaged']>
+  private readonly readyTimeoutMs: number
 
   constructor(options: DashboardSupervisorOptions) {
     this.command = options.command ?? 'hermes'
@@ -72,6 +77,13 @@ export class DashboardSupervisor {
     this.log = options.log ?? console.info
     this.intervalMs = options.intervalMs ?? SUPERVISION_INTERVAL_MS
     this.managed = options.managed === true
+    this.spawnManaged = options.spawnManaged ?? spawn
+    this.readyTimeoutMs = options.readyTimeoutMs ?? RESTART_READY_TIMEOUT_MS
+  }
+
+  /** Only a live child started by this supervisor may be restarted from the UI. */
+  get canRestart(): boolean {
+    return this.managed && !this.stopped && !!this.owned?.pid && this.owned.exitCode === null && this.owned.signalCode === null
   }
 
   start(): void {
@@ -88,49 +100,60 @@ export class DashboardSupervisor {
   }
 
   async restart(): Promise<void> {
-    while (this.checking) await delay(25)
-    if (this.managed) {
-      if (!this.owned) throw new Error('此 Hermes 由外部服务管理,不能由桌面 App 停止。')
-      await this.stopOwned()
-      this.stopped = false
-      await this.checkNow()
-      return
-    }
-    this.run(['dashboard', '--stop'])
-    await this.checkNow()
-    const deadline = Date.now() + RESTART_READY_TIMEOUT_MS
-    while (Date.now() < deadline) {
-      if (await this.probe('127.0.0.1', DASHBOARD_PORT)) return
-      await delay(100)
+    if (this.restarting) throw new Error('Hermes Dashboard 正在重启,请等待完成。')
+    this.restarting = true
+    try {
+      while (this.checking) await delay(25)
+      if (this.stopped) throw new Error('后台服务正在停止,无法重启 Hermes Dashboard。')
+      if (this.managed) {
+        if (!this.canRestart) throw new Error('此 Hermes 由外部服务管理,不能由桌面 App 停止。')
+        await this.stopOwned()
+      } else this.run(['dashboard', '--stop'])
+      await this.ensureRunning()
+      const deadline = Date.now() + this.readyTimeoutMs
+      while (Date.now() < deadline) {
+        if (this.stopped || (this.managed && !this.canRestart)) throw new Error('Hermes Dashboard 启动失败。')
+        if (await this.probe('127.0.0.1', DASHBOARD_PORT)) {
+          if (this.stopped || (this.managed && !this.canRestart)) throw new Error('Hermes Dashboard 启动失败。')
+          return
+        }
+        await delay(100)
+      }
+      throw new Error('Hermes Dashboard 重启后未在规定时间内恢复,请重新检查。')
+    } finally {
+      this.restarting = false
     }
-    throw new Error('Hermes Dashboard did not return on 9119 after restart')
   }
 
   async checkNow(): Promise<void> {
-    if (this.checking) return
+    if (this.checking || this.restarting) return
     this.checking = true
     try {
-      // Installation must not rewrite credentials or restart/rebind an existing
-      // upstream. Only a missing managed service is started on loopback.
-      const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
-      if (running || this.stopped || this.owned) return
-      this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
-      if (this.managed) {
-        const env: NodeJS.ProcessEnv = { ...process.env, HERMES_PARENT_PID: String(process.pid) }
-        for (const key of Object.keys(env)) if (key.startsWith('HERMES_YAOYAO_') || key.startsWith('ELECTRON_')) delete env[key]
-        const child = spawn(this.command, ['dashboard', '--host', this.dashboardHost, '--no-open', '--skip-build'], {
-          env, detached: process.platform !== 'win32', stdio: 'ignore',
-        })
-        this.owned = child
-        child.once('error', error => { if (this.owned === child) this.owned = undefined; this.log(`Hermes 启动失败:${error.message}`) })
-        child.once('exit', () => { if (this.owned === child) this.owned = undefined })
-      } else this.launch(['dashboard', '--host', this.dashboardHost])
+      await this.ensureRunning()
     } catch (error) {
       this.log(`Hermes Dashboard supervision failed: ${error instanceof Error ? error.message : String(error)}`)
     } finally {
       this.checking = false
     }
   }
+
+  private async ensureRunning(): Promise<void> {
+    // Installation must not rewrite credentials or restart/rebind an existing
+    // upstream. Only a missing managed service is started on loopback.
+    const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
+    if (running || this.stopped || this.owned) return
+    this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
+    if (this.managed) {
+      const env: NodeJS.ProcessEnv = { ...process.env, HERMES_PARENT_PID: String(process.pid) }
+      for (const key of Object.keys(env)) if (key.startsWith('HERMES_YAOYAO_') || key.startsWith('ELECTRON_')) delete env[key]
+      const child = this.spawnManaged(this.command, ['dashboard', '--host', this.dashboardHost, '--no-open', '--skip-build'], {
+        env, detached: process.platform !== 'win32', stdio: 'ignore',
+      })
+      this.owned = child
+      child.once('error', error => { if (this.owned === child) this.owned = undefined; this.log(`Hermes 启动失败:${error.message}`) })
+      child.once('exit', () => { if (this.owned === child) this.owned = undefined })
+    } else this.launch(['dashboard', '--host', this.dashboardHost])
+  }
   async stopOwned(): Promise<void> {
     const child = this.owned
     if (!child || !child.pid || child.exitCode !== null || child.signalCode !== null) return

+ 40 - 7
src/server/hermesBridge.ts

@@ -8,13 +8,14 @@ import {z} from 'zod'
 import {HttpError} from './errors.js'
 import {isLoopbackHost,type ServerConfig} from './config.js'
 import type {UpstreamServiceSession} from './localAuth.js'
-import type {HermesBridgeStatus,HermesBridgeProfileStatus,HermesBridgeInstallResult} from '../shared/hermesBridge.js'
+import type {DashboardSupervisor} from './dashboardSupervisor.js'
+import type {HermesBridgeStatus,HermesBridgeProfileStatus,HermesBridgeInstallResult,HermesDashboardRestartResult} from '../shared/hermesBridge.js'
 
 const execFileAsync=promisify(execFile)
 const profileName=z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/)
 const installInput=z.object({profile:profileName,enable:z.boolean().default(false)}).strict()
 interface LocalProfile {profile:string;exists:boolean;valid:boolean;installedVersion?:string;enabled?:boolean;disabled?:boolean;fingerprint?:string;filesCurrent?:boolean;message?:string}
-interface Options {home?:string;python?:string;assetsRoot?:string;isIdle?:()=>boolean;run?:(python:string,args:string[])=>Promise<string>}
+interface Options {home?:string;python?:string;assetsRoot?:string;isIdle?:()=>boolean;isRestartIdle?:()=>boolean;dashboard?:Pick<DashboardSupervisor,'canRestart'|'restart'>;run?:(python:string,args:string[])=>Promise<string>}
 
 function assetRoot(){
   const base=dirname(fileURLToPath(import.meta.url))
@@ -31,8 +32,9 @@ export class HermesBridgeManager {
   readonly local:boolean
   readonly mapped:boolean
   private installing?:string
+  private restarting=false
   private readonly run:(python:string,args:string[])=>Promise<string>
-  constructor(readonly config:ServerConfig,readonly session:Pick<UpstreamServiceSession,'request'>,readonly options:Options={}){
+  constructor(readonly config:ServerConfig,readonly session:Pick<UpstreamServiceSession,'request'> & Partial<Pick<UpstreamServiceSession,'invalidateAuthentication'>>,readonly options:Options={}){
     this.mapped=!!config.hermesBridgeMount
     this.home=resolve(options.home??config.hermesBridgeMount?.home??process.env.HERMES_HOME??join(homedir(),'.hermes'))
     this.python=options.python??config.hermesBridgeMount?.python??join(this.home,'hermes-agent','venv','bin','python')
@@ -44,7 +46,19 @@ export class HermesBridgeManager {
     this.local=this.mapped||(isLoopbackHost(config.upstream.hostname)&&config.localVmHost!=='runner')
     this.run=options.run??(async(python,args)=>(await execFileAsync(python,args,{timeout:20000,maxBuffer:512*1024,env:{PATH:process.env.PATH,HOME:homedir(),PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'}})).stdout)
   }
-  get idleForUpdate(){return !this.installing}
+  get idleForUpdate(){return !this.installing&&!this.restarting}
+  get dashboardRestarting(){return this.restarting}
+  assertDashboardAvailable(){if(this.restarting)throw new HttpError(409,'Hermes Dashboard 正在重启,请稍后再提交任务。','hermes_dashboard_restarting')}
+  private dashboardStatus(restartComplete=false):NonNullable<HermesBridgeStatus['dashboard']>{
+    const restarting=this.restarting&&!restartComplete
+    const managed=this.local&&!this.mapped&&this.config.upstream.origin==='http://127.0.0.1:9119'&&!!this.options.dashboard?.canRestart
+    const busy=!(this.options.isIdle?.()??true)
+    return {managed:managed||restarting,restarting,canRestart:managed&&!restarting&&!this.installing&&!busy,
+      message:restarting?'正在重启 Hermes Dashboard 并检查工具桥加载状态…':
+        !managed?'仅支持重启由 Yaoyao 自己启动并管理的本机 Hermes Dashboard。':
+        this.installing?'工具桥正在安装,请等待完成后重启。':busy?'当前仍有任务运行,请结束任务后重启。':
+        '重启会短暂断开此 Dashboard 下所有 Profile 的连接,完成后自动检查工具桥。'}
+  }
   private get available(){return this.local&&this.bundled&&existsSync(this.python)&&existsSync(join(this.home,'config.yaml'))}
   private async disk():Promise<LocalProfile[]>{
     if(!this.available)return []
@@ -53,7 +67,8 @@ export class HermesBridgeManager {
       return output.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(item=>profileName.safeParse(item.profile).success).slice(0,64)
     }catch{throw new HttpError(409,'无法检查工具桥,请确认 Hermes 目录权限、Python 环境和 Profile 配置可用。','hermes_bridge_check_failed')}
   }
-  async status():Promise<HermesBridgeStatus>{
+  async status():Promise<HermesBridgeStatus>{return this.snapshot()}
+  private async snapshot(restartComplete=false):Promise<HermesBridgeStatus>{
     const [disk,remote]=await Promise.allSettled([this.disk(),this.session.request('/api/profiles',{cache:'reload'})])
     const localProfiles=disk.status==='fulfilled'?disk.value:[]
     const connected=remote.status==='fulfilled'&&remote.value.status===200
@@ -70,7 +85,7 @@ export class HermesBridgeManager {
       const local=localProfiles.find(item=>item.profile===profile)
       let live:any,liveStatus=0
       if(connected)try{const response=await this.session.request('/api/plugins/yaoyao-bot-bridge/capabilities',{search:new URLSearchParams({profile}),cache:'reload'});liveStatus=response.status;if(response.status===200)live=JSON.parse(response.body.toString())}catch{}
-      const result:HermesBridgeProfileStatus={profile,state:'unavailable',message:'无法连接 Hermes 或读取工具桥状态',canInstall:this.available&&local?.valid===true&&!this.installing&&(this.options.isIdle?.()??true),
+      const result:HermesBridgeProfileStatus={profile,state:'unavailable',message:'无法连接 Hermes 或读取工具桥状态',canInstall:this.available&&local?.valid===true&&!this.installing&&(!this.restarting||restartComplete)&&(this.options.isIdle?.()??true),
         ...(local?.installedVersion?{installedVersion:local.installedVersion}:{}),...(typeof live?.plugin_version==='string'?{loadedVersion:live.plugin_version}:{})}
       const ready=live?.ready===true&&live?.native_tools===true&&live?.in_process===true&&live?.computer_runtime_version===2
       if(local){
@@ -88,7 +103,7 @@ export class HermesBridgeManager {
       if(this.local&&profile!=='default'&&!hostEntryInstalled){result.canInstall=false;result.message+=';请先安装或更新默认 Profile 的工具桥入口'}
       return result
     })))
-    return {endpoint:this.config.upstream.origin,local:this.local,...(this.mapped?{mapped:true}:{}),bundledVersion:this.bundledVersion,checkedAt:Date.now(),profiles,...(this.installing?{installing:this.installing}:{}),
+    return {endpoint:this.config.upstream.origin,local:this.local,dashboard:this.dashboardStatus(restartComplete),...(this.mapped?{mapped:true}:{}),bundledVersion:this.bundledVersion,checkedAt:Date.now(),profiles,...(this.installing?{installing:this.installing}:{}),
       ...(!this.local?{message:'当前 Hermes 由其他节点提供,请在 Hermes 所在节点安装或修复工具桥。'}:
         !this.bundled?{message:'当前服务未包含工具桥安装包,请更新 Yaoyao。'}:
         !this.available?{message:this.mapped?`映射目录 ${this.home} 或容器 Python 不可用,请检查目录映射和读取权限。`:'当前服务环境未找到本机 Hermes 安装目录和 Python,无法直接安装。'}:
@@ -99,6 +114,7 @@ export class HermesBridgeManager {
   async install(value:unknown):Promise<HermesBridgeInstallResult>{
     const parsed=installInput.safeParse(value)
     if(!parsed.success)throw new HttpError(400,'请选择有效的 Profile','invalid_bridge_install_request')
+    this.assertDashboardAvailable()
     if(!this.available)throw new HttpError(409,'只能为当前服务可管理的本机或显式映射的 Hermes 目录安装工具桥。','hermes_bridge_install_unavailable')
     if(this.installing)throw new HttpError(409,'工具桥正在安装,请等待完成。','hermes_bridge_install_busy')
     if(this.options.isIdle&&!this.options.isIdle())throw new HttpError(409,'当前仍有任务运行,请结束任务后安装。','hermes_bridge_tasks_running')
@@ -122,4 +138,21 @@ export class HermesBridgeManager {
       +'请在空闲时重启 Hermes Dashboard 服务,然后重新检查。'
     return {profile:parsed.data.profile,backup:result.backup,message,status:await this.status()}
   }
+  async restartDashboard():Promise<HermesDashboardRestartResult>{
+    this.assertDashboardAvailable()
+    if(!this.dashboardStatus().managed)throw new HttpError(409,'只能重启由 Yaoyao 自己启动并管理的本机 Hermes Dashboard。','hermes_dashboard_restart_unavailable')
+    if(this.installing)throw new HttpError(409,'工具桥正在安装,请等待完成。','hermes_bridge_install_busy')
+    if(!(this.options.isIdle?.()??true)||!(this.options.isRestartIdle?.()??true))throw new HttpError(409,'当前仍有任务运行,请结束任务后重启。','hermes_bridge_tasks_running')
+    this.restarting=true
+    try{
+      await this.options.dashboard!.restart()
+      this.session.invalidateAuthentication?.()
+      const status=await this.snapshot(true)
+      if(!status.dashboard?.managed)throw new Error('Dashboard ownership lost during verification')
+      const ready=status.profiles.every(profile=>profile.state==='ready')
+      return {message:ready?'Hermes Dashboard 已重启,工具桥已就绪。':'Hermes Dashboard 已重启。部分 Profile 的工具桥尚未就绪,请查看各 Profile 状态。',status}
+    }catch{
+      throw new HttpError(409,'Hermes Dashboard 重启或状态检查失败,请重新检查连接和工具桥状态。','hermes_dashboard_restart_failed')
+    }finally{this.restarting=false}
+  }
 }

+ 1 - 3
src/server/index.ts

@@ -22,14 +22,12 @@ if (config.home === legacyDataHome() && process.platform === 'darwin') {
 }
 const instance = acquireServiceInstance(config.home, readReleaseManifest(resolve(process.cwd(), 'release.json')).webVersion, process.env.HERMES_YAOYAO_DESKTOP === '1', readBuildIdentity(process.cwd()))
 process.once('exit', () => instance.release())
-const runtime = createApplication({
-  config,
-})
 const dashboardSupervisor = config.superviseDashboard
   && isLocalAuthorizationTarget(config.upstream) && config.upstream.hostname === '127.0.0.1'
   && config.upstream.protocol === 'http:' && config.upstream.port === '9119'
   ? new DashboardSupervisor({ managed: process.env.HERMES_YAOYAO_DESKTOP === '1' })
   : undefined
+const runtime = createApplication({config, dashboardSupervisor})
 const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined
 // Native update admission runs before routes (including streamed requests).

+ 2 - 0
src/server/realtimeBroker.ts

@@ -47,6 +47,7 @@ export class RealtimeBroker {
     return this.commands.size === 0 && [...this.upstreams.values()].every(upstream => upstream.pending.size === 0 && [...upstream.routes.values()].every(route => !route.active))
   }
   protectedSession: (id: string) => boolean = () => false
+  assertCanCommand:()=>void=()=>{}
   onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
   onNativeGlobalEvent: (owner: string, type: string, upstreamInvalidation?: boolean) => void = () => {}
   onNativeCommand: (owner: string, profile: string, storedId: string, method: string, params: Frame) => void = () => {}
@@ -141,6 +142,7 @@ export class RealtimeBroker {
 
   command(c: RealtimeChannel, requestId: string, input: Frame): Promise<CommandReceipt> {
     if (this.closed) throw new HttpError(503, 'Realtime broker is stopping', 'broker_stopping')
+    this.assertCanCommand()
     if (c.kind !== 'chat') throw new HttpError(403, 'Group event channel is read-only', 'read_only')
     const normalized = checkedChatFrame(Buffer.from(JSON.stringify(input)), false, c.principal.paired)
     const frame = JSON.parse(normalized) as Frame

+ 4 - 0
src/server/routes.ts

@@ -1792,6 +1792,10 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     dependencies.auth.requireAdmin(ctx)
     json(ctx,200,await dependencies.hermesBridge.install(body(ctx)))
   })
+  router.post('/api/app/admin/hermes-bridge/restart', async ctx => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx,200,await dependencies.hermesBridge.restartDashboard())
+  })
   router.get('/api/app/admin/model-services', async (ctx) => {
     await proxyAdminFeature(ctx, dependencies, '/api/providers/custom-endpoints', {
       search: searchFrom(ctx, ['profile']),

+ 2 - 1
src/server/workspaceRoutines.ts

@@ -29,6 +29,7 @@ export function nextRoutineAt(s:WorkspaceSchedule,after:number):number|undefined
 }
 const terminal=(status:string)=>['complete','failed','interrupted','skipped'].includes(status)
 export class WorkspaceRoutines {
+  paused:()=>boolean=()=>false
   private timer?:ReturnType<typeof setInterval>;private ticking=false;private closed=false
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly runtime:WorkspaceRuntime){}
   private agent(owner:string,id:string){const agent=this.store.require<WorkspaceAgent>(owner,'agent',id);if(agent.archived||agent.temporaryGoalId||agent.remoteAgentId)throw new HttpError(409,'此机器人不能建立定时任务','routine_agent_unavailable');this.nodes.requireSource(owner,agent);return agent}
@@ -62,7 +63,7 @@ export class WorkspaceRoutines {
     }))
   }
   tick(now=Date.now()){
-    if(this.closed||this.ticking)return;this.ticking=true
+    if(this.closed||this.ticking||this.paused())return;this.ticking=true
     try {for(const owner of this.store.owners())for(const routine of this.store.list<WorkspaceRoutine>(owner,'routine')){
       if(!routine.enabled||!routine.nextAt||routine.nextAt>now)continue
       this.store.atomic(()=>{

+ 2 - 0
src/server/workspaceRuntime.ts

@@ -112,6 +112,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   readonly knowledgeTools: WorkspaceKnowledgeTools
   onMemoryCandidate: (owner: string, run: Run) => void = () => {}
   plugins?: WorkspacePlugins
+  assertCanSubmit:()=>void=()=>{}
   get idleForUpdate(): boolean {
     return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()
   }
@@ -186,6 +187,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     peerMessageId?: string; collaborationChainId?: string; priority?: boolean; projectId?: string; deviceHost?: string
   }): Run {
     if (!this.userActive(owner)) throw new HttpError(403,'账号授权已失效','account_inactive')
+    this.assertCanSubmit()
     const body = parse(sendInput, input)
     for (const id of this.store.require<Conversation>(owner, 'conversation', conversationId).memberIds)
       this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', id))

+ 5 - 0
src/shared/hermesBridge.ts

@@ -8,6 +8,7 @@ export interface HermesBridgeProfileStatus {
   canInstall: boolean
 }
 export interface HermesBridgeStatus {
+  dashboard?: { managed: boolean; canRestart: boolean; restarting: boolean; message: string }
   endpoint: string
   local: boolean
   mapped?: boolean
@@ -17,6 +18,10 @@ export interface HermesBridgeStatus {
   message?: string
   profiles: HermesBridgeProfileStatus[]
 }
+export interface HermesDashboardRestartResult {
+  message: string
+  status: HermesBridgeStatus
+}
 export interface HermesBridgeInstallResult {
   profile: string
   backup: string

+ 50 - 2
tests/client/hermesBridgePanel.test.ts

@@ -1,9 +1,9 @@
 import {flushPromises,mount} from '@vue/test-utils'
 import {afterEach,beforeEach,expect,it,vi} from 'vitest'
 import HermesBridgePanel from '@/components/app/HermesBridgePanel.vue'
-import {getHermesBridgeStatus,installHermesBridge} from '@/api/hermesBridge'
+import {getHermesBridgeStatus,installHermesBridge,restartHermesDashboard} from '@/api/hermesBridge'
 import type {HermesBridgeStatus} from '@shared/hermesBridge'
-vi.mock('@/api/hermesBridge',()=>({getHermesBridgeStatus:vi.fn(),installHermesBridge:vi.fn()}))
+vi.mock('@/api/hermesBridge',()=>({getHermesBridgeStatus:vi.fn(),installHermesBridge:vi.fn(),restartHermesDashboard:vi.fn()}))
 const snapshot=():HermesBridgeStatus=>({endpoint:'http://127.0.0.1:9119',local:true,bundledVersion:'1.2.0',checkedAt:1,profiles:[
   {profile:'default',state:'outdated',message:'需要更新',canInstall:true,installedVersion:'1.1.0'},
   {profile:'writer',state:'disabled',message:'尚未启用',canInstall:true,installedVersion:'1.2.0'},
@@ -47,3 +47,51 @@ it('shows installation failures without claiming that Hermes has loaded the plug
   expect(wrapper.text()).not.toContain('已就绪');expect(wrapper.text()).not.toContain('安装中…')
   wrapper.unmount()
 })
+
+const managedSnapshot=():HermesBridgeStatus=>({...snapshot(),dashboard:{managed:true,canRestart:true,restarting:false,message:'重启会短暂断开此 Dashboard 下所有 Profile 的连接,完成后自动检查工具桥。'}})
+it('shows one instance-wide restart action with progress and the verified result',async()=>{
+  vi.mocked(getHermesBridgeStatus).mockResolvedValue(managedSnapshot())
+  let finish!:(value:any)=>void
+  vi.mocked(restartHermesDashboard).mockReturnValue(new Promise(done=>{finish=done}))
+  const wrapper=mount(HermesBridgePanel);await flushPromises()
+  expect(wrapper.findAll('.restart')).toHaveLength(1)
+  expect(wrapper.text()).toContain('所有 Profile')
+  await wrapper.get('.restart').trigger('click');await wrapper.get('.restart').trigger('click')
+  expect(restartHermesDashboard).toHaveBeenCalledTimes(1)
+  expect(wrapper.get('.restart').text()).toBe('重启中…')
+  expect(wrapper.attributes('aria-busy')).toBe('true')
+  expect(wrapper.findAll('button').every(b=>b.attributes('disabled')!==undefined)).toBe(true)
+  const state=managedSnapshot();state.profiles.forEach(p=>{p.state='ready'})
+  finish({message:'Hermes Dashboard 已重启,工具桥已就绪。',status:state});await flushPromises()
+  expect(wrapper.get('[role="status"]').text()).toContain('工具桥已就绪')
+  expect(wrapper.findAll('.badge.ready')).toHaveLength(2)
+  expect(wrapper.get('.restart').attributes('disabled')).toBeUndefined()
+  wrapper.unmount()
+})
+
+it('hides restart for unmanaged instances and disables it while tasks or installs are active',async()=>{
+  const state=managedSnapshot();state.dashboard!.canRestart=false;state.dashboard!.message='当前仍有任务运行,请结束任务后重启。'
+  vi.mocked(getHermesBridgeStatus).mockResolvedValue(state)
+  const wrapper=mount(HermesBridgePanel);await flushPromises()
+  expect(wrapper.get('.restart').attributes('disabled')).toBeDefined()
+  expect(wrapper.text()).toContain('当前仍有任务运行')
+  await wrapper.get('.restart').trigger('click');expect(restartHermesDashboard).not.toHaveBeenCalled()
+  state.dashboard!.managed=false
+  await wrapper.findAll('button').find(b=>b.text()==='重新检查')!.trigger('click');await flushPromises()
+  expect(wrapper.find('.restart').exists()).toBe(false)
+  expect(wrapper.text()).toContain('Hermes 所在节点')
+  wrapper.unmount()
+})
+
+it('refreshes capabilities after failure and preserves the restart error',async()=>{
+  vi.mocked(getHermesBridgeStatus).mockResolvedValue(managedSnapshot())
+  vi.mocked(restartHermesDashboard).mockRejectedValue(new Error('Hermes Dashboard 重启失败'))
+  const wrapper=mount(HermesBridgePanel);await flushPromises()
+  const state=managedSnapshot();state.dashboard!.canRestart=false;state.dashboard!.managed=false
+  vi.mocked(getHermesBridgeStatus).mockResolvedValue(state)
+  await wrapper.get('.restart').trigger('click');await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toContain('重启失败')
+  expect(wrapper.find('.restart').exists()).toBe(false)
+  expect(wrapper.text()).not.toContain('工具桥已就绪')
+  wrapper.unmount()
+})

+ 26 - 6
tests/e2e/hermes-bridge.spec.ts

@@ -1,12 +1,13 @@
 import {test,expect} from '@playwright/test'
 import {mkdirSync} from 'node:fs'
-const evidence='docs/verification/2026-09-15-hermes-bridge'
+const evidence='test-results/hermes-dashboard-restart'
 
 test('checks and installs the selected Profile bridge and displays its actual activation state',async({page})=>{
   mkdirSync(evidence,{recursive:true})
-  let phase=0,finishInstall!:(value:void)=>void
-  const installations:unknown[]=[]
-  const status=()=>({endpoint:'http://127.0.0.1:9119',local:true,bundledVersion:'1.2.0',checkedAt:Date.now(),profiles:[
+  let phase=0,managed=true,finishInstall!:(value:void)=>void,finishRestart!:(value:void)=>void
+  const installations:unknown[]=[],restarts:unknown[]=[]
+  const status=()=>({endpoint:'http://127.0.0.1:9119',local:true,bundledVersion:'1.2.0',checkedAt:Date.now(),
+    dashboard:{managed,canRestart:managed,restarting:false,message:'重启会短暂断开此 Dashboard 下所有 Profile 的连接,完成后自动检查工具桥。'},profiles:[
     {profile:'default',state:'ready',message:'Hermes 已加载当前工具桥,可以使用',installedVersion:'1.2.0',loadedVersion:'1.2.0',canInstall:true},
     {profile:'server',state:phase===0?'missing':phase===1?'restart-required':'ready',message:phase===0?'尚未安装工具桥插件':phase===1?'插件文件已就位;请在空闲时重启 Hermes 后重新检查':'Hermes 已加载当前工具桥,可以使用',installedVersion:phase?'1.2.0':undefined,loadedVersion:phase===2?'1.2.0':undefined,canInstall:true},
   ]})
@@ -17,6 +18,12 @@ test('checks and installs the selected Profile bridge and displays its actual ac
     phase=1
     await route.fulfill({json:{profile:'server',backup:'/Users/fixture/.hermes/profiles/server/backups/yaoyao-bridge-test',message:'工具桥已安装并启用。请在空闲时重启 Hermes,然后重新检查。',status:status()}})
   })
+  await page.route('**/api/app/admin/hermes-bridge/restart',async route=>{
+    restarts.push(route.request().postDataJSON())
+    await new Promise<void>(done=>{finishRestart=done})
+    phase=2
+    await route.fulfill({json:{message:'Hermes Dashboard 已重启,工具桥已就绪。',status:status()}})
+  })
   await page.goto('/conversations')
   await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
   await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
@@ -35,9 +42,19 @@ test('checks and installs the selected Profile bridge and displays its actual ac
   await expect(panel.getByText('待重启',{exact:true})).toBeVisible()
   expect(installations).toEqual([{profile:'server',enable:true}])
   await page.screenshot({path:evidence+'/desktop-installed.png'})
-  phase=2
-  await panel.getByRole('button',{name:'重新检查',exact:true}).click()
+  await page.setViewportSize({width:375,height:812})
+  await panel.getByRole('button',{name:'重启 Hermes Dashboard',exact:true}).scrollIntoViewIfNeeded()
+  expect(await panel.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
+  await page.screenshot({path:evidence+'/mobile-restart.png'})
+  await panel.getByRole('button',{name:'重启 Hermes Dashboard',exact:true}).click()
+  await expect(panel.getByRole('button',{name:'重启中…',exact:true})).toBeDisabled()
+  await expect(panel.getByRole('button',{name:'重新检查',exact:true})).toBeDisabled()
+  await expect(panel).toHaveAttribute('aria-busy','true')
+  await page.screenshot({path:evidence+'/mobile-restarting.png'})
+  finishRestart()
   await expect(panel.getByText('已就绪',{exact:true})).toHaveCount(2)
+  await expect(panel.getByRole('status')).toContainText('Hermes Dashboard 已重启,工具桥已就绪。')
+  expect(restarts).toEqual([{}])
   await page.setViewportSize({width:375,height:812})
   await panel.scrollIntoViewIfNeeded()
   await page.screenshot({path:evidence+'/mobile-ready.png'})
@@ -51,4 +68,7 @@ test('checks and installs the selected Profile bridge and displays its actual ac
   await page.evaluate(()=>document.documentElement.classList.add('dark'))
   await panel.scrollIntoViewIfNeeded()
   await page.screenshot({path:evidence+'/dark-ready.png'})
+  managed=false
+  await panel.getByRole('button',{name:'重新检查',exact:true}).click()
+  await expect(panel.getByRole('button',{name:'重启 Hermes Dashboard',exact:true})).toHaveCount(0)
 })

+ 67 - 1
tests/server/dashboardSupervisor.test.ts

@@ -1,4 +1,6 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+import { EventEmitter } from 'node:events'
+import type { ChildProcess } from 'node:child_process'
 import { DashboardSupervisor } from '../../src/server/dashboardSupervisor.js'
 
 function supervisor(initiallyRunning = false) {
@@ -45,3 +47,67 @@ describe('DashboardSupervisor', () => {
     expect(f.launches).toHaveLength(1)
   })
 })
+
+function managedSupervisor(initiallyRunning=false, readyTimeoutMs=1000) {
+  let running=initiallyRunning
+  const children:ChildProcess[]=[]
+  const spawnManaged=vi.fn(()=>{
+    const child=Object.assign(new EventEmitter(),{pid:10000+children.length,exitCode:null as number|null,signalCode:null as NodeJS.Signals|null,
+      kill:vi.fn((signal:NodeJS.Signals)=>{running=false;child.signalCode=signal;child.emit('exit',null,signal);return true})}) as unknown as ChildProcess
+    children.push(child)
+    return child
+  })
+  const instance=new DashboardSupervisor({managed:true,spawnManaged,probe:async()=>running,readyTimeoutMs,log:()=>{}})
+  return {instance,spawnManaged,children,listening:(value=true)=>{running=value}}
+}
+
+it('never adopts or restarts an externally started Dashboard',async()=>{
+  const f=managedSupervisor(true)
+  await f.instance.checkNow()
+  expect(f.instance.canRestart).toBe(false)
+  await expect(f.instance.restart()).rejects.toThrow('外部服务管理')
+  expect(f.spawnManaged).not.toHaveBeenCalled()
+  expect(supervisor(true).instance.canRestart).toBe(false)
+})
+
+it('restarts only its owned child, serializes requests and waits for the replacement listener',async()=>{
+  const f=managedSupervisor()
+  await f.instance.checkNow();f.listening()
+  expect(f.instance.canRestart).toBe(true)
+  let completed=false
+  const pending=f.instance.restart().then(()=>{completed=true})
+  await vi.waitFor(()=>expect(f.children).toHaveLength(2))
+  expect(f.children[0]!.kill).toHaveBeenCalledWith('SIGTERM')
+  expect(completed).toBe(false)
+  await expect(f.instance.restart()).rejects.toThrow('正在重启')
+  await f.instance.checkNow()
+  expect(f.spawnManaged).toHaveBeenCalledTimes(2)
+  f.listening();await pending
+  expect(completed).toBe(true)
+  expect(f.instance.canRestart).toBe(true)
+  f.instance.stop();await f.instance.stopOwned()
+})
+
+it('reports startup failure and timeout instead of claiming a successful restart',async()=>{
+  const failed=managedSupervisor()
+  await failed.instance.checkNow()
+  const pending=failed.instance.restart()
+  const rejected=expect(pending).rejects.toThrow('启动失败')
+  await vi.waitFor(()=>expect(failed.children).toHaveLength(2))
+  failed.children[1]!.emit('error',new Error('spawn failed'))
+  await rejected
+  expect(failed.instance.canRestart).toBe(false)
+  const timeout=managedSupervisor(false,10)
+  await timeout.instance.checkNow()
+  await expect(timeout.instance.restart()).rejects.toThrow('规定时间')
+  timeout.instance.stop();await timeout.instance.stopOwned()
+})
+
+it('does not relaunch an owned Dashboard when shutdown starts during restart',async()=>{
+  const f=managedSupervisor()
+  await f.instance.checkNow()
+  f.children[0]!.kill=vi.fn(()=>{f.instance.stop();f.children[0]!.signalCode='SIGTERM';f.children[0]!.emit('exit');return true})
+  await expect(f.instance.restart()).rejects.toThrow('启动失败')
+  expect(f.spawnManaged).toHaveBeenCalledTimes(1)
+  expect(f.instance.canRestart).toBe(false)
+})

+ 133 - 4
tests/server/hermesBridge.test.ts

@@ -13,8 +13,8 @@ beforeEach(()=>{
   home=mkdtempSync(join(tmpdir(),'yaoyao-bridge-manager-'));writeFileSync(join(home,'config.yaml'),'{}')
   config={host:'127.0.0.1',port:15300,upstream:new URL('http://127.0.0.1:9119'),home,allowedHosts:new Set(),mediaRoot:home,attachmentsRoot:home,imagesRoot:home,mediaOwner:'fixture',allowInsecureLan:false,insecureLan:false,production:false}
 })
-afterEach(()=>{rmSync(home,{recursive:true,force:true});vi.restoreAllMocks()})
-function fixture(){
+afterEach(()=>{rmSync(home,{recursive:true,force:true});vi.restoreAllMocks();vi.unstubAllEnvs()})
+function fixture(dashboard?:{canRestart:boolean;restart:()=>Promise<void>}){
   let disk:any[]=[{profile:'default',exists:true,valid:true,enabled:true,installedVersion:'1.1.0',fingerprint:'old',filesCurrent:false},{profile:'writer',exists:true,valid:true,enabled:false,disabled:true,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true}]
   let live:any={ready:true,native_tools:true,in_process:true,computer_runtime_version:2,plugin_version:'1.1.0',plugin_fingerprint:'old'}
   let idle=true,gate:Promise<void>|undefined
@@ -25,8 +25,8 @@ function fixture(){
     disk=disk.map(row=>row.profile===profile?{...row,installedVersion:'1.2.0',fingerprint:'new',filesCurrent:true,enabled:true,disabled:false}:row)
     return JSON.stringify({profile,backup:join(home,'backups',profile),disabled:false})
   })
-  const session={request:vi.fn(async(path:string)=>({status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'},{name:'writer'}]}:live))}))}
-  const manager=new HermesBridgeManager(config,session,{home,python:process.execPath,assetsRoot:resolve('.'),run,isIdle:()=>idle})
+  const session={invalidateAuthentication:vi.fn(),request:vi.fn(async(path:string)=>({status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path==='/api/profiles'?{profiles:[{name:'default'},{name:'writer'}]}:live))}))}
+  const manager=new HermesBridgeManager(config,session,{home,python:process.execPath,assetsRoot:resolve('.'),run,isIdle:()=>idle,dashboard})
   return {manager,run,session,setLive:(value:any)=>{live=value},ready:()=>{live={...live,plugin_fingerprint:'new',plugin_version:'1.2.0'}},busy:()=>{idle=false},wait:(value:Promise<void>)=>{gate=value}}
 }
 it('distinguishes stale files, disabled Profile, pending restart and the actually loaded plugin',async()=>{
@@ -43,6 +43,135 @@ it('distinguishes stale files, disabled Profile, pending restart and the actuall
   f.ready();expect((await f.manager.status()).profiles.find(p=>p.profile==='writer')?.state).toBe('ready')
   expect(installed.backup).toBe(join(home,'backups','writer'))
 })
+
+it('restarts the managed Dashboard and verifies every Profile using fresh loaded fingerprints',async()=>{
+  const dashboard={canRestart:true,restart:vi.fn(async()=>{f.ready()})},f=fixture(dashboard)
+  await f.manager.install({profile:'default'});await f.manager.install({profile:'writer',enable:true})
+  expect((await f.manager.status()).dashboard).toMatchObject({managed:true,canRestart:true,restarting:false})
+  const result=await f.manager.restartDashboard()
+  expect(dashboard.restart).toHaveBeenCalledTimes(1)
+  expect(f.session.invalidateAuthentication).toHaveBeenCalledTimes(1)
+  expect(result.status.profiles.every(p=>p.state==='ready'&&p.canInstall)).toBe(true)
+  expect(result.status.dashboard).toMatchObject({canRestart:true,restarting:false})
+  expect(result.message).toContain('工具桥已就绪')
+})
+
+it('keeps an unverified bridge pending after restart and releases the lock after failure',async()=>{
+  const dashboard={canRestart:true,restart:vi.fn(async()=>{})},f=fixture(dashboard)
+  await f.manager.install({profile:'default'})
+  const result=await f.manager.restartDashboard()
+  expect(result.status.profiles[0]!.state).toBe('restart-required')
+  expect(result.message).toContain('尚未就绪')
+  dashboard.restart.mockRejectedValueOnce(new Error('private process diagnostics'))
+  await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_failed'})
+  expect(f.manager.dashboardRestarting).toBe(false)
+  expect(f.manager.idleForUpdate).toBe(true)
+  expect((await f.manager.status()).dashboard?.canRestart).toBe(true)
+})
+
+it('blocks concurrent restart, installation and task admission until verification finishes',async()=>{
+  let release!:()=>void
+  const dashboard={canRestart:true,restart:vi.fn(()=>new Promise<void>(done=>{release=done}))},f=fixture(dashboard)
+  const pending=f.manager.restartDashboard()
+  expect(f.manager.idleForUpdate).toBe(false)
+  expect(()=>f.manager.assertDashboardAvailable()).toThrow('正在重启')
+  expect((await f.manager.status()).dashboard).toMatchObject({managed:true,canRestart:false,restarting:true})
+  await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
+  await expect(f.manager.install({profile:'default'})).rejects.toMatchObject({code:'hermes_dashboard_restarting'})
+  release();await pending
+  expect(()=>f.manager.assertDashboardAvailable()).not.toThrow()
+  expect(dashboard.restart).toHaveBeenCalledTimes(1)
+  f.busy()
+  expect((await f.manager.status()).dashboard?.canRestart).toBe(false)
+  await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_bridge_tasks_running'})
+})
+
+it('refuses restart during installation and when process ownership is lost after the status check',async()=>{
+  let release!:()=>void
+  const dashboard={canRestart:true,restart:vi.fn(async()=>{})},f=fixture(dashboard)
+  f.wait(new Promise<void>(done=>{release=done}))
+  const installation=f.manager.install({profile:'default'})
+  await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_bridge_install_busy'})
+  release();await installation
+  expect((await f.manager.status()).dashboard?.canRestart).toBe(true)
+  dashboard.canRestart=false
+  await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_unavailable'})
+  expect(dashboard.restart).not.toHaveBeenCalled()
+})
+
+it('does not infer restart permission from local addresses or mounted directories',async()=>{
+  const dashboard={canRestart:true,restart:vi.fn(async()=>{})},f=fixture()
+  await expect(f.manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_unavailable'})
+  for(const override of [
+    {upstream:new URL('https://hermes.example')},
+    {upstream:new URL('http://127.0.0.1:9120')},
+    {localVmHost:'runner' as const},
+    {hermesBridgeMount:{home,python:process.execPath}},
+  ]){
+    const manager=new HermesBridgeManager({...config,...override},f.session,{home,python:process.execPath,assetsRoot:resolve('.'),run:f.run,dashboard})
+    expect((await manager.status()).dashboard?.managed).toBe(false)
+    await expect(manager.restartDashboard()).rejects.toMatchObject({code:'hermes_dashboard_restart_unavailable'})
+  }
+  expect(dashboard.restart).not.toHaveBeenCalled()
+})
+
+it('requires admin and CSRF for restart, and blocks API and websocket task entry during restart',async()=>{
+  let release!:()=>void
+  const dashboard={canRestart:true,restart:vi.fn(()=>new Promise<void>(done=>{release=done}))},f=fixture(dashboard)
+  const admin=createAuthenticatedApplication({config,hermesBridge:f.manager})
+  try{
+    const api=request.agent(admin.app.callback()),host='127.0.0.1:15300',path='/api/app/admin/hermes-bridge/restart'
+    await api.post(path).set('Host',host).send({}).expect(403)
+    const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
+    const post=(url:string)=>api.post(url).set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({})
+    const pending=post(path).expect(200).then(response=>response)
+    await vi.waitFor(()=>expect(dashboard.restart).toHaveBeenCalledTimes(1))
+    expect((await post(path).expect(409)).body.code).toBe('hermes_dashboard_restarting')
+    expect((await post('/api/app/agents').expect(409)).body.code).toBe('hermes_dashboard_restarting')
+    expect(()=>admin.realtime.broker.command({} as any,'request',{})).toThrow('正在重启')
+    vi.spyOn(admin.workspaceRuntime,'userActive').mockReturnValue(true)
+    expect(()=>admin.workspaceRuntime.send('test-admin','unused',{})).toThrow('正在重启')
+    const owners=vi.spyOn(admin.workspace,'owners');owners.mockClear()
+    admin.workspaceRoutines.tick();expect(owners).not.toHaveBeenCalled();owners.mockRestore()
+    release();const response=await pending
+    expect(response.body.status.dashboard.restarting).toBe(false)
+    expect(()=>admin.workspaceRuntime.assertCanSubmit()).not.toThrow()
+  }finally{release?.();admin.close()}
+  const user=createUserAuthenticatedApplication({config,hermesBridge:f.manager})
+  try{
+    const api=request.agent(user.app.callback()),host='127.0.0.1:15300'
+    const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
+    await api.post('/api/app/admin/hermes-bridge/restart').set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({}).expect(403)
+    expect(dashboard.restart).toHaveBeenCalledTimes(1)
+  }finally{user.close()}
+})
+
+it('checks runtime activity and already admitted HTTP requests before acquiring the restart lock',async()=>{
+  vi.stubEnv('HERMES_HOME',home)
+  const dashboard={canRestart:true,restart:vi.fn(async()=>{})}
+  const admin=createAuthenticatedApplication({config,dashboardSupervisor:dashboard})
+  let release!:()=>void,entered=false
+  const gate=new Promise<void>(done=>{release=done})
+  admin.app.use(async ctx=>{if(ctx.path==='/restart-admission-fixture'){entered=true;await gate;ctx.body={ok:true}}})
+  vi.spyOn(admin.upstreamSession,'request').mockImplementation(async()=>({status:200,headers:new Headers(),body:Buffer.from('{"profiles":[]}')}))
+  try{
+    const host='127.0.0.1:15300',api=request.agent(admin.app.callback())
+    const csrf=(await api.get('/api/app/bootstrap?csrfOnly=1').set('Host',host).expect(200)).body.csrfToken
+    const restart=()=>api.post('/api/app/admin/hermes-bridge/restart').set('Host',host).set('Origin','http://'+host).set('X-CSRF-Token',csrf).send({})
+    for(const component of [admin.workspaceRuntime,admin.runners,admin.realtime.broker,admin.desktopEnvironments]){
+      const idle=vi.spyOn(component,'idleForUpdate','get').mockReturnValue(false)
+      expect((await restart().expect(409)).body.code).toBe('hermes_bridge_tasks_running')
+      idle.mockRestore()
+    }
+    const pending=api.post('/restart-admission-fixture').set('Host',host).send({}).expect(200).then(r=>r)
+    await vi.waitFor(()=>expect(entered).toBe(true))
+    expect((await restart().expect(409)).body.code).toBe('hermes_bridge_tasks_running')
+    expect(dashboard.restart).not.toHaveBeenCalled()
+    release();await pending
+    await restart().expect(200)
+    expect(dashboard.restart).toHaveBeenCalledTimes(1)
+  }finally{release();admin.close()}
+})
 it('prevents concurrent installs and rejects path injection, unknown profiles and installation during work',async()=>{
   const f=fixture();let release!:()=>void
   f.wait(new Promise<void>(done=>{release=done}))