Эх сурвалжийг харах

fix(local-vm): 共享模式保持单一虚拟机并让成员可接力使用

- 归并旧执行节点滞留的共享组,重新注册后不再分裂出第二台虚拟机
- 切换共享/独立或启用虚拟机后停止退役桌面,释放运行数量配额
- 资源池巡检重试未确认的停止,uncertain 环境不再永久阻塞
Samien 1 сар өмнө
parent
commit
2188193847

+ 1 - 1
docs/local-vm.md

@@ -6,7 +6,7 @@
 
 1. 进入应用设置 → **本地虚拟机**。macOS App 的应用菜单也提供“本地虚拟机设置…”。
 2. 检查 Docker/Podman 已安装并启动,在 **准备虚拟机镜像** 中选择 **标准桌面** 或 **Cursor Universal**,点击 **准备所选镜像**。两个镜像可分别准备;会复用对应的兼容镜像或构建固定版本,验证驱动和实际截图后标记就绪。准备前先停止所有虚拟机任务和实例。外部执行节点需更新配套 Runner。
-3. 选择 **共享虚拟机** 或 **每个 Agent 独立**,最多同时运行 1–4 台,默认 2 台。同一账号、同一执行节点的持久 Agent 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。
+3. 选择 **共享虚拟机** 或 **每个 Agent 独立**,最多同时运行 1–4 台,默认 2 台。同一账号、同一执行节点的持久 Agent 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。切换共享方式或重建本机执行节点时,服务会自动把成员归并到当前唯一共享组,并停止不再使用的旧桌面,避免旧实例占用运行数量上限。
 4. 打开 Agent 聊天,点击右上角 **电脑**。右侧电脑面板中选择 **本地虚拟机**,在 **虚拟机镜像** 中选择已准备的镜像,再创建桌面。独立桌面可分别选择不同镜像并同时运行;同一个共享桌面的所有成员只能使用这台桌面的一个镜像。
 5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
 6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。

+ 4 - 0
src/runner/computers/pool.ts

@@ -198,7 +198,11 @@ export class ComputerPool {
       if(entry.status==='idle'&&this.idleStopMinutes>0&&this.now()-entry.updatedAt>=this.idleStopMinutes*60000){this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{});continue}
       if((entry.status==='active'||entry.status==='preparing')&&entry.expiresAt<=this.now()){
         this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
+        continue
       }
+      // An unconfirmed stop must not block the environment forever: retry it
+      // on later sweeps until the provider confirms the desktop has stopped.
+      if(entry.status==='uncertain'&&this.now()-entry.updatedAt>=30000)await this.serial(id,()=>this.stopEntry(entry)).catch(()=>{})
     }
   }
   async close():Promise<void>{

+ 21 - 3
src/server/localVm.ts

@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
 import { join } from 'node:path'
 import { execFile } from 'node:child_process'
 import { promisify } from 'node:util'
+import { randomUUID } from 'node:crypto'
 import { RunnerAgent } from '../runner/agent.js'
 import { UNCONFIGURED_COMPUTER_IMAGE, type RunnerConfiguration } from '../shared/runner.js'
 import type { LocalVmMode, LocalVmStatus } from '../shared/localVm.js'
@@ -43,6 +44,18 @@ export class LocalVmService {
     return !!grant&&grant.runnerId===runnerId&&grant.expiresAt>Date.now()&&this.auth.isAdminActive(grant.owner)&&this.auth.pushAuthorizationVersion(grant.owner)===grant.version
   }
   private mode(owner:string):LocalVmMode { return this.fixed?'shared':this.store.get<{mode:LocalVmMode}>(owner,'local-vm-preferences','local')?.mode??'per-bot' }
+  /** Heals groups stranded on a superseded runner record; without it those
+   * members keep failing with shared_runner_changed and never rejoin sharing. */
+  private reconcile(owner:string,record:{id:string}|undefined){
+    if(this.fixed||!record||this.mode(owner)!=='shared')return
+    try{this.shared.reconcileLocalVmGroups(owner,record.id)}catch{/* best effort; retried on the next check */}
+  }
+  private async stopDesktops(owner:string,runnerId:string,environmentIds:string[]){
+    for(const environmentId of environmentIds){
+      const id=randomUUID();this.grant(owner,id,runnerId)
+      await this.hub.localVm(owner,runnerId,{op:'instance',id,environmentId,action:'stop'}).catch(()=>{})
+    }
+  }
   private record(){return this.hub.records().find(r=>r.enabled&&r.sourceNodeId==='local'&&r.sourceOwner==='_system')}
   async start(url:string) {
     this.url=url
@@ -97,6 +110,7 @@ export class LocalVmService {
     })().finally(()=>{this.starting=undefined})
     await this.starting
     const record=this.record()!
+    this.reconcile(owner,record)
     const deadline=Date.now()+8000
     while(!this.hub.summary(record).online&&Date.now()<deadline)await new Promise(r=>setTimeout(r,100))
     return record
@@ -113,6 +127,7 @@ export class LocalVmService {
       const runner=record?this.hub.summary(record):undefined,connected=runner?.online&&runner.features.includes('compose-desktops-v1')
       return {configured:!!connected,executionHost:'runner',runnerName:record?.name,fixedCapacity:true,desktops,daemonUp:true,image:desktops.some(d=>d.ready),mode:'shared',maxInstances:desktops.length,busy:false,...(!connected?{setupRequired:'runner' as const,problem:'Compose 桌面数量已固定。请连接启用 Compose 桌面模式的 Hermes 执行节点。'}:{})}
     }
+    this.reconcile(owner,record)
     const executionHost=this.config.localVmHost==='runner'||(record&&!this.store.get('_system','local-vm-managed','local'))?'runner':'server'
     if(record){
       if(this.url&&!this.managed)await this.start(this.url)
@@ -155,8 +170,9 @@ export class LocalVmService {
       const record=await this.ensure(owner);this.shared.assertLocalVmIdle(owner)
       this.grant(owner,body.requestId,record.id)
       await this.hub.localVm(owner,record.id,{op:'policy',id:body.requestId,mode:body.mode,maxInstances:body.maxInstances})
-      this.shared.setLocalVmMode(owner,body.mode,record.id)
+      const {retiredEnvironmentIds}=this.shared.setLocalVmMode(owner,body.mode,record.id)
       this.store.put(owner,'local-vm-preferences','local',{mode:body.mode})
+      await this.stopDesktops(owner,record.id,retiredEnvironmentIds)
       ctx.body=await this.status(owner)
     })
     router.post('/api/app/admin/local-vm/instances/:id/:action',async ctx=>{
@@ -175,7 +191,7 @@ export class LocalVmService {
       try { ctx.body={enabled:true,agent,...await this.hub.computer(owner,agent,'detail',{},()=>this.nodes.requireSource(owner,agent)),mode:agent.computerEnvironmentId?'shared':'per-bot',...fixed} }
       catch(error){ctx.body={enabled:true,agent,container:'missing',ready:false,image:false,problem:error instanceof Error?error.message:'本地虚拟机不可用',...fixed}}
     })
-    router.put('/api/app/agents/:id/local-vm',ctx=>{
+    router.put('/api/app/agents/:id/local-vm',async ctx=>{
       const owner=this.auth.require(ctx).id,body=parse(z.object({enabled:z.boolean(),desktopId:z.string().uuid().optional()}).strict(),(ctx.request as any).body)
       const agent=this.store.require<WorkspaceAgent>(owner,'agent',ctx.params.id);this.nodes.requireSource(owner,agent)
       if(agent.remoteAgentId||agent.temporaryGoalId)throw new HttpError(409,'请在该机器人所属的电脑上设置虚拟机','local_vm_remote')
@@ -187,12 +203,14 @@ export class LocalVmService {
         if(!record||!this.hub.summary(record).features.includes('compose-desktops-v1'))throw new HttpError(409,'请连接启用 Compose 桌面模式的执行节点','compose_runner_required')
         ctx.body=this.store.atomic(()=>{this.shared.bindCompose(owner,agent,desktop,record.id);return {agent:this.store.agentSummary(agent)}});return
       }
+      let retiredAfterEnable:string[]=[]
       ctx.body=this.store.atomic(()=>{
         if(!body.enabled)this.shared.detachLocalVm(owner,agent)
         this.store.updateAgent(owner,agent.id,{execution:body.enabled?'computer':'profile',computer:body.enabled?'vm':'off'})
-        if(body.enabled&&!this.fixed&&this.mode(owner)==='shared'){const record=this.record();if(record)this.shared.setLocalVmMode(owner,'shared',record.id)}
+        if(body.enabled&&!this.fixed&&this.mode(owner)==='shared'){const record=this.record();if(record)retiredAfterEnable=this.shared.setLocalVmMode(owner,'shared',record.id).retiredEnvironmentIds}
         return {agent:this.store.agentSummary(this.store.require<WorkspaceAgent>(owner,'agent',agent.id))}
       })
+      if(retiredAfterEnable.length){const record=this.record();if(record)await this.stopDesktops(owner,record.id,retiredAfterEnable)}
     })
     router.put('/api/app/agents/:id/local-vm/image',async ctx=>{
       const owner=this.auth.require(ctx).id;this.assertManaged()

+ 37 - 6
src/server/sharedComputers.ts

@@ -27,6 +27,29 @@ export class SharedComputers {
     if(this.store.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(ids.includes(grant.agentId)||(grant.environmentId&&environments.has(grant.environmentId)))))throw new HttpError(409,'请先交还电脑控制权,再修改共享','shared_computer_busy')
   }
   private localVmAgents(owner:string){return this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local'&&!a.archived&&!a.remoteAgentId&&!a.temporaryGoalId&&(a.execution==='computer'||!!a.computerEnvironmentId))}
+  /** A runner re-enrollment must not fork a second shared desktop: fold every
+   * stray managed group into one canonical group owned by the current runner,
+   * keeping the member-rich group so shared files and browser data survive. */
+  reconcileLocalVmGroups(owner:string,runnerId:string):SharedComputer|undefined{
+    const groups=this.store.list<SharedComputer>(owner,'shared-computer').filter(g=>g.nodeId==='local'&&!g.archived&&g.managedLocalVm)
+    const canonical=groups.find(g=>g.runnerId===runnerId)??[...groups].sort((a,b)=>b.memberIds.length-a.memberIds.length)[0]
+    if(!canonical)return undefined
+    canonical.runnerId=runnerId
+    for(const stray of groups){
+      if(stray===canonical)continue
+      for(const id of stray.memberIds){
+        const member=this.store.get<WorkspaceAgent>(owner,'agent',id)
+        if(member&&member.computerEnvironmentId===stray.id){
+          member.computerEnvironmentId=canonical.id;member.computerEnvironmentName=canonical.name;member.revision++;member.updatedAt=Date.now()
+          this.store.put(owner,'agent',id,member);this.store.event(owner,'agent.changed',member)
+        }
+        if(!canonical.memberIds.includes(id))canonical.memberIds.push(id)
+      }
+      stray.archived=true;this.store.put(owner,'shared-computer',stray.id,stray)
+    }
+    this.store.put(owner,'shared-computer',canonical.id,canonical)
+    return canonical
+  }
   assertLocalVmIdle(owner:string,ids?:string[]){this.idle(owner,ids??this.localVmAgents(owner).map(a=>a.id))}
   bindCompose(owner:string,agent:WorkspaceAgent,desktop:{id:string;name:string},runnerId:string,persist=true){
     this.idle(owner,[agent.id])
@@ -43,7 +66,7 @@ export class SharedComputers {
   attachLocalVm(owner:string,agent:WorkspaceAgent,runnerId:string){
     if(agent.nodeId!=='local'||agent.execution!=='computer'||agent.temporaryGoalId||agent.remoteAgentId||agent.computerEnvironmentId)return
     this.nodes.requireSource(owner,agent)
-    const group=this.store.list<SharedComputer>(owner,'shared-computer').find(g=>!g.archived&&g.managedLocalVm&&g.runnerId===runnerId)
+    const group=this.reconcileLocalVmGroups(owner,runnerId)
       ??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:'*',runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
     if(!group.memberIds.includes(agent.id))group.memberIds.push(agent.id)
     this.store.put(owner,'shared-computer',group.id,group)
@@ -57,12 +80,15 @@ export class SharedComputers {
     delete agent.computerEnvironmentId;delete agent.computerEnvironmentName
     this.store.put(owner,'agent',agent.id,agent)
   }
-  setLocalVmMode(owner:string,mode:'shared'|'per-bot',runnerId:string){
+  /** Returns the desktop environments this switch orphaned, so the caller can
+   * stop them on the runner; a leftover per-bot desktop would otherwise keep
+   * consuming the concurrency quota and block the shared desktop's members. */
+  setLocalVmMode(owner:string,mode:'shared'|'per-bot',runnerId:string):{retiredEnvironmentIds:string[]}{
     this.assertLocalVmIdle(owner)
-    this.store.atomic(()=>{
+    return this.store.atomic(()=>{
       const agents=this.localVmAgents(owner)
       const previous=new Map(agents.map(a=>[a.id,{id:a.computerEnvironmentId,name:a.computerEnvironmentName}]))
-      const groups=this.store.list<SharedComputer>(owner,'shared-computer').filter(g=>g.nodeId==='local'&&g.runnerId===runnerId)
+      const groups=this.store.list<SharedComputer>(owner,'shared-computer').filter(g=>g.nodeId==='local'&&(g.managedLocalVm||g.runnerId===runnerId))
       for(const group of groups){group.archived=true;this.store.put(owner,'shared-computer',group.id,group)}
       for(const agent of agents){
         if(agent.computerEnvironmentId&&groups.some(g=>g.id===agent.computerEnvironmentId)){delete agent.computerEnvironmentId;delete agent.computerEnvironmentName}
@@ -70,16 +96,21 @@ export class SharedComputers {
       if(mode==='shared'){
         const members=agents.filter(a=>a.execution==='computer'&&!a.computerEnvironmentId)
         if(members.length){
-          const group=groups.find(g=>g.managedLocalVm)??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:'*',runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
-          group.profile='*';group.archived=false;group.managedLocalVm=true;group.memberIds=members.map(a=>a.id);this.store.put(owner,'shared-computer',group.id,group)
+          const group=[...groups.filter(g=>g.managedLocalVm)].sort((a,b)=>b.memberIds.length-a.memberIds.length)[0]
+            ??{id:randomUUID(),name:'共享本地虚拟机',memberIds:[],nodeId:'local',profile:'*',runnerId,archived:false,createdAt:Date.now(),managedLocalVm:true}
+          group.profile='*';group.archived=false;group.managedLocalVm=true;group.runnerId=runnerId;group.memberIds=members.map(a=>a.id);this.store.put(owner,'shared-computer',group.id,group)
           for(const agent of members){agent.computerEnvironmentId=group.id;agent.computerEnvironmentName=group.name}
         }
       }
+      const retiredEnvironmentIds:string[]=[]
       for(const agent of agents){
         const before=previous.get(agent.id)!
         if(before.id===agent.computerEnvironmentId&&before.name===agent.computerEnvironmentName)continue
         agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)
+        const retired=before.id??agent.id
+        if(retired!==(agent.computerEnvironmentId??agent.id))retiredEnvironmentIds.push(retired)
       }
+      return {retiredEnvironmentIds}
     })
   }
   create(owner:string,input:unknown){

+ 13 - 0
tests/server/computerPool.test.ts

@@ -67,6 +67,19 @@ it('keeps an unconfirmed stop reserved and recovers it before a new acquisition'
   expect(next.generation).toBeGreaterThan(lease.generation)
   await restarted.close()
 })
+it('retries an unconfirmed stop on later sweeps so the environment does not stay blocked',async()=>{
+  const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'task',()=>{})
+  vi.mocked(f.provider.stop).mockRejectedValueOnce(new Error('daemon unavailable'))
+  await expect(f.pool.release(lease)).rejects.toThrow('daemon unavailable')
+  expect(f.pool.status('owner')[0]?.status).toBe('uncertain')
+  f.advance(29999);await f.pool.expire()
+  expect(f.pool.status('owner')[0]?.status).toBe('uncertain')
+  f.advance(1);await f.pool.expire()
+  expect(f.pool.status('owner')[0]?.status).toBe('free')
+  const next=await f.pool.acquire(resource,'new-task',()=>{})
+  expect(next.generation).toBeGreaterThan(lease.generation)
+  await f.pool.close()
+})
 it('does not expire a new holder acquired while an earlier environment stop was in flight',async()=>{
   const f=fixture();await f.pool.recover();const a=spec(),b=spec()
   const first=await f.pool.acquire(a,'first',()=>{}),second=await f.pool.acquire(b,'second',()=>{})

+ 5 - 0
tests/server/localVmRoutes.test.ts

@@ -48,6 +48,10 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
   await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'shared',maxInstances:2}).expect(200)
   const a=store.require<any>('alice','agent',first.id),b=store.require<any>('alice','agent',second.id)
   expect(a.computerEnvironmentId).toBeTruthy();expect(b.computerEnvironmentId).toBe(a.computerEnvironmentId)
+  // Switching isolation stops the per-bot desktops these members no longer use,
+  // so they cannot occupy the concurrency quota and block the shared desktop.
+  expect(hub.localVm).toHaveBeenCalledWith('alice',runner.id,expect.objectContaining({op:'instance',environmentId:first.id,action:'stop'}))
+  expect(hub.localVm).toHaveBeenCalledWith('alice',runner.id,expect.objectContaining({op:'instance',environmentId:second.id,action:'stop'}))
   await request(app.callback()).put(`/api/app/agents/${second.id}/local-vm/image`).send({imageKey:'standard'}).expect(200)
   expect(hub.computer).toHaveBeenLastCalledWith('alice',expect.objectContaining({computerEnvironmentId:a.computerEnvironmentId}),'image',{imageKey:'standard'},expect.any(Function))
   expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
@@ -59,6 +63,7 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
   expect(store.require<any>('bob','agent',other.id).computerEnvironmentId).toBeUndefined()
   await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'per-bot',maxInstances:2}).expect(200)
   expect(store.require<any>('alice','agent',first.id).computerEnvironmentId).toBeUndefined()
+  expect(hub.localVm).toHaveBeenCalledWith('alice',runner.id,expect.objectContaining({op:'instance',environmentId:a.computerEnvironmentId,action:'stop'}))
   expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
   store.put('alice','turn','active-vm',{agentId:first.id,status:'uncertain'})
   hub.localVm.mockClear()

+ 32 - 0
tests/server/sharedComputers.test.ts

@@ -50,6 +50,38 @@ it('shares one desktop across authorized profiles and rejects temporary helpers'
     expect(()=>service.create('owner',command)).toThrow('持久隔离成员')
   }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
 })
+it('keeps one shared group across runner re-enrollment instead of stranding members',()=>{
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-runner-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{} as any)
+  try{
+    const first=store.createAgent('owner',{name:'第一位',execution:'computer',profile:'default'})
+    service.attachLocalVm('owner',first,'runner-one');store.put('owner','agent',first.id,first)
+    const original=store.require<WorkspaceAgent>('owner','agent',first.id).computerEnvironmentId!
+    // The managed runner is removed and re-enrolled with a new identity.
+    const second=store.createAgent('owner',{name:'第二位',execution:'computer',profile:'default'})
+    service.attachLocalVm('owner',second,'runner-two');store.put('owner','agent',second.id,second)
+    const groups=store.list<WorkspaceAgent>('owner','shared-computer').filter((group:any)=>!group.archived)
+    expect(groups).toHaveLength(1)
+    expect((groups[0] as any).runnerId).toBe('runner-two')
+    expect((groups[0] as any).memberIds).toEqual(expect.arrayContaining([first.id,second.id]))
+    expect(store.require<WorkspaceAgent>('owner','agent',first.id).computerEnvironmentId).toBe(original)
+    expect(store.require<WorkspaceAgent>('owner','agent',second.id).computerEnvironmentId).toBe(original)
+    expect(service.reconcileLocalVmGroups('owner','runner-two')!.id).toBe(original)
+  }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
+})
+it('reports desktops orphaned by an isolation switch so the caller can stop them',()=>{
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-retire-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{} as any)
+  try{
+    const vm=store.createAgent('owner',{name:'虚拟机成员',execution:'computer',profile:'default'})
+    // Entering shared mode retires this member's previous per-bot desktop.
+    expect(service.setLocalVmMode('owner','shared','runner').retiredEnvironmentIds).toEqual([vm.id])
+    const group=store.require<WorkspaceAgent>('owner','agent',vm.id).computerEnvironmentId!
+    // Leaving shared mode retires the now unused shared desktop.
+    expect(service.setLocalVmMode('owner','per-bot','runner').retiredEnvironmentIds).toEqual([group])
+    // Re-entering shared mode retires whatever per-bot desktop this member used meanwhile.
+    expect(service.setLocalVmMode('owner','shared','runner').retiredEnvironmentIds).toEqual([vm.id])
+    expect(service.setLocalVmMode('owner','shared','runner').retiredEnvironmentIds).toEqual([])
+  }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
+})
 it('changes only persistent local VM members and leaves unrelated running chats untouched',()=>{
   const home=mkdtempSync(join(tmpdir(),'yaoyao-shared-scope-')),store=new WorkspaceStore(home),nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any),service=new SharedComputers(store,{} as any,nodes,{} as any)
   try{