Browse Source

修复本地虚拟机共享切换误判忙碌并保留空闲桌面

LSamien 1 month ago
parent
commit
ca900f3fe9

+ 2 - 0
docs/local-vm.md

@@ -13,6 +13,8 @@
 
 虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;空闲 5 分钟后自动停止。取消、授权撤销、异常退出仍停止旧执行实例,以隔离迟到操作。基础 Profile 工作目录变化会在空闲时重建运行实例,数据保留。
 
+切换共享方式不会修改普通 Profile 成员,也不会因其聊天任务而被阻止。已经交还控制权的空闲桌面可保持运行;虚拟机任务仍在执行、人工控制尚未交还或停止状态待核对时,继续保护原执行并拒绝切换。成员任务造成阻挡时,提示会显示具体成员名称。
+
 `terminal.cwd` 默认的 `.`(以及 `auto`、`cwd`、未设置)指向虚拟机的持久工作区 `/home/cua/workspace`;相对路径以该工作区为起点,显式绝对路径继续沿用配置。创建、启动和人工接管只读取工作目录,不要求模型可用;停止和移除已有实例也不依赖 Profile 配置。模型仅在运行 Agent 时解析,Profile、目录和模型错误分别提示。
 
 ## 手机端

+ 3 - 1
src/runner/computers/localVm.ts

@@ -65,7 +65,9 @@ export class LocalVmImages {
   }
   async policy(mode:LocalVmMode,maxInstances:number) {
     await this.ready
-    const release=this.runtime.pool.beginMaintenance()
+    // Sharing changes future Agent bindings; an idle desktop can keep running.
+    // Image preparation still requires every desktop to be stopped.
+    const release=this.runtime.pool.beginMaintenance({allowIdle:true})
     try { this.state.mode=mode;this.state.maxInstances=maxInstances;this.runtime.pool.limits.concurrent=maxInstances;this.save() }
     finally { release() }
     return this.status()

+ 2 - 2
src/runner/computers/pool.ts

@@ -17,8 +17,8 @@ export class ComputerPool {
   private closing=false
   private maintenance=false
   holdMaintenance(){this.maintenance=true;let released=false;return ()=>{if(!released){released=true;this.maintenance=false}}}
-  beginMaintenance(){
-    if(this.maintenance||this.changing.size||this.rows().some(row=>row.status!=='free'))throw new ComputerError('computer_busy','请先停止电脑任务并交还控制权,再管理镜像')
+  beginMaintenance({allowIdle=false}={}){
+    if(this.maintenance||this.changing.size||this.rows().some(row=>row.status!=='free'&&!(allowIdle&&row.status==='idle')))throw new ComputerError('computer_busy',allowIdle?'请先停止正在使用虚拟机的任务并交还控制权,再修改虚拟机设置':'请先停止电脑任务并交还控制权,再管理镜像')
     return this.holdMaintenance()
   }
   constructor(readonly db:DatabaseSync,readonly provider:ComputerProvider,readonly limits={concurrent:2,environments:32,ttlMs:30000},readonly now=Date.now) {

+ 17 - 5
src/server/sharedComputers.ts

@@ -17,10 +17,17 @@ export function requireSharedComputer(store:WorkspaceStore,owner:string,agent:{i
 export class SharedComputers {
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly nodes:WorkspaceNodes,readonly hub:RunnerHub){}
   private idle(owner:string,ids:string[],environmentId?:string){
-    if(this.store.list<{agentId:string;status:string}>(owner,'turn').some(work=>ids.includes(work.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止这些成员的当前任务,再修改电脑共享','shared_computer_busy')
-    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)||(environmentId&&grant.environmentId===environmentId))))throw new HttpError(409,'请先交还电脑控制权,再修改共享','shared_computer_busy')
+    const busy=new Set(this.store.list<{agentId:string;status:string}>(owner,'turn').filter(work=>ids.includes(work.agentId)&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)).map(work=>work.agentId))
+    if(busy.size){
+      const names=[...busy].slice(0,5).map(id=>`「${this.store.get<WorkspaceAgent>(owner,'agent',id)?.name??'未知成员'}」`).join('、')
+      throw new HttpError(409,`请先停止${names}${busy.size>5?'等成员':''}的当前任务,再修改电脑共享`,'shared_computer_busy')
+    }
+    const environments=new Set(ids.map(id=>this.store.get<WorkspaceAgent>(owner,'agent',id)?.computerEnvironmentId??id))
+    if(environmentId)environments.add(environmentId)
+    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')
   }
-  assertLocalVmIdle(owner:string,ids?:string[]){this.idle(owner,ids??this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local').map(a=>a.id))}
+  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))}
+  assertLocalVmIdle(owner:string,ids?:string[]){this.idle(owner,ids??this.localVmAgents(owner).map(a=>a.id))}
   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)
@@ -41,7 +48,8 @@ export class SharedComputers {
   setLocalVmMode(owner:string,mode:'shared'|'per-bot',runnerId:string){
     this.assertLocalVmIdle(owner)
     this.store.atomic(()=>{
-      const agents=this.store.list<WorkspaceAgent>(owner,'agent').filter(a=>a.nodeId==='local'&&!a.archived&&!a.remoteAgentId&&!a.temporaryGoalId)
+      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)
       for(const group of groups){group.archived=true;this.store.put(owner,'shared-computer',group.id,group)}
       for(const agent of agents){
@@ -53,7 +61,11 @@ export class SharedComputers {
         group.archived=false;group.managedLocalVm=true;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}
       }
-      for(const agent of agents){agent.revision++;agent.updatedAt=Date.now();this.store.put(owner,'agent',agent.id,agent);this.store.event(owner,'agent.changed',agent)}
+      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)
+      }
     })
   }
   create(owner:string,input:unknown){

+ 27 - 1
tests/live/localVm.live.test.ts

@@ -46,7 +46,7 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   const page=await context.newPage()
   const inputs:unknown[]=[],failures:unknown[]=[]
   page.on('request',r=>{if(r.url().endsWith('/computer/input'))inputs.push(r.postDataJSON()?.action)})
-  page.on('response',r=>{if(r.url().includes('/computer')&&r.status()>=400)void r.json().then(value=>failures.push(value))})
+  page.on('response',r=>{if((r.url().includes('/computer')||r.url().includes('/local-vm'))&&r.status()>=400)void r.json().then(value=>failures.push(value))})
   await page.goto(address+'/conversations')
   await page.getByRole('textbox',{name:'账号',exact:true}).fill('vm-ui-fixture')
   await page.getByRole('textbox',{name:'密码',exact:true}).fill('vm-ui-fixture-password')
@@ -113,6 +113,32 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   await expect.poll(()=>desktops.locator('.pane').last().locator('.computer-screen.controlling').count(),{timeout:15000}).toBe(1)
   await page.screenshot({path:join(evidence,'two-desktops.png')})
   await desktops.getByRole('button',{name:'交还并关闭',exact:true}).click()
+  await expect.poll(()=>desktops.count(),{timeout:15000}).toBe(0)
+  expect(app.workspace.list<any>('_system','computer-control').some(g=>g.expiresAt>Date.now())).toBe(false)
+  // A Profile chat must not prevent switching idle VMs between shared and
+  // per-Agent mode. Keep this synthetic busy marker inside the isolated DB.
+  const chatting=app.workspace.createAgent(owner,{name:'普通聊天进行中',profile:'default'})
+  const ordinaryWorkId='sharing-policy-ordinary-chat'
+  app.workspace.put(owner,'turn',ordinaryWorkId,{agentId:chatting.id,status:'running'})
+  try{
+    await page.getByRole('button',{name:'设置与模式',exact:true}).first().click()
+    await page.getByRole('menuitem',{name:'进入设置',exact:true}).click()
+    await settings.getByRole('button',{name:'本地虚拟机',exact:true}).click()
+    page.once('dialog',dialog=>{void dialog.accept()})
+    await settings.getByRole('button',{name:'共享虚拟机',exact:true}).click()
+    try{await expect.poll(()=>settings.getByRole('button',{name:'共享虚拟机',exact:true}).getAttribute('aria-pressed'),{timeout:15000}).toBe('true')}
+    catch(error){await page.screenshot({path:join(evidence,'sharing-failed.png')});throw new Error(`${String(error)}\n${JSON.stringify({failures,settings:await settings.innerText(),vm:await app.localVm.status(owner)})}`)}
+    const group=app.workspace.require<any>(owner,'agent',agent.id).computerEnvironmentId
+    expect(group).toBeTruthy()
+    expect(app.workspace.require<any>(owner,'agent',second.id).computerEnvironmentId).toBe(group)
+    expect(app.workspace.require(owner,'agent',chatting.id)).toEqual(chatting)
+    await settings.getByRole('button',{name:'每个 Agent 独立',exact:true}).click()
+    await expect.poll(()=>settings.getByRole('button',{name:'每个 Agent 独立',exact:true}).getAttribute('aria-pressed'),{timeout:15000}).toBe('true')
+    expect(app.workspace.require<any>(owner,'agent',agent.id).computerEnvironmentId).toBeUndefined()
+    expect(app.workspace.require(owner,'agent',chatting.id)).toEqual(chatting)
+    expect(await readFile(join(workspace,'proof.txt'),'utf8')).toBe('vm-ui-proof')
+    await page.screenshot({path:join(evidence,'sharing-policy.png')})
+  }finally{app.workspace.remove(owner,'turn',ordinaryWorkId)}
  }finally{
   const runnerId=app.runners.records()[0]?.id
   // Shut down while the browser is still attached. All owned desktops must

+ 23 - 0
tests/server/localVm.test.ts

@@ -70,6 +70,29 @@ it('refuses lifecycle changes while a human or Agent holds the VM',async()=>{
   await f.runtime.pool.release(lease)
  }finally{await f.close()}
 })
+it('allows sharing policy changes with idle desktops but preserves active leases and image maintenance guards',async()=>{
+ const f=await fixture(),manager=new LocalVmImages(f.runtime,f.db),spec={id:randomUUID(),ownerKey:'owner',imageId:'sha256:'+'a'.repeat(64)}
+ try{
+  await manager.ready
+  await f.runtime.pool.desktop(spec,'create',()=>{})
+  expect(f.runtime.pool.status('owner')[0]?.status).toBe('idle')
+  await manager.policy('shared',3)
+  expect(f.running.has(spec.id)).toBe(true)
+  expect((await manager.status()).mode).toBe('shared')
+  expect(f.runtime.pool.limits.concurrent).toBe(3)
+  expect(()=>f.runtime.pool.beginMaintenance()).toThrow()
+  const lease=await f.runtime.pool.acquire(spec,'human:fixture',()=>{})
+  await expect(manager.policy('per-bot',2)).rejects.toMatchObject({code:'computer_busy'})
+  expect((await manager.status()).mode).toBe('shared')
+  expect(f.runtime.pool.limits.concurrent).toBe(3)
+  await f.runtime.pool.release(lease,true)
+  await manager.policy('per-bot',2)
+  expect((await manager.status()).mode).toBe('per-bot')
+  expect(f.running.has(spec.id)).toBe(true)
+  await f.runtime.pool.desktop(spec,'stop',()=>{})
+  const release=f.runtime.pool.beginMaintenance();release()
+ }finally{await manager.close();await f.close()}
+})
 it('creates without a model and can stop or remove an existing desktop after Profile resolution fails',async()=>{
  const f=await fixture(),meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'owner'}
  try{

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

@@ -23,6 +23,8 @@ it('protects preparation, removes the old catalog API and scopes sharing to the
  app.use(async(ctx,next)=>{try{await next()}catch(e){ctx.status=(e as any).status||500;ctx.body={error:String(e)}}});app.use(bodyParser());app.use(service.router().routes())
  try{
   const first=store.createAgent('alice',{name:'甲',profile:'default',execution:'computer'}),second=store.createAgent('alice',{name:'乙',profile:'default',execution:'computer'})
+  const chatting=store.createAgent('alice',{name:'普通聊天成员',profile:'server'})
+  store.put('alice','turn','ordinary-chat',{agentId:chatting.id,status:'running'})
   const other=store.createAgent('bob',{name:'其他账号',profile:'default',execution:'computer'})
   const id=randomUUID(),base='/api/app/admin/local-vm'
   await request(app.callback()).post(base+'/prepare').send({requestId:id}).expect(403)
@@ -35,6 +37,7 @@ 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)
+  expect(store.require('alice','agent',chatting.id)).toEqual(chatting)
   const later=store.createAgent('alice',{name:'后加入成员',profile:'default',execution:'computer'})
   expect(later.computerEnvironmentId).toBe(a.computerEnvironmentId)
   const disabled=await request(app.callback()).put(`/api/app/agents/${later.id}/local-vm`).send({enabled:false}).expect(200)
@@ -43,6 +46,13 @@ 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(store.require('alice','agent',chatting.id)).toEqual(chatting)
+  store.put('alice','turn','active-vm',{agentId:first.id,status:'uncertain'})
+  hub.localVm.mockClear()
+  const blocked=await request(app.callback()).put(base+'/policy').set('x-admin','yes').send({requestId:randomUUID(),mode:'shared',maxInstances:2}).expect(409)
+  expect(blocked.body.error).toContain('请先停止「甲」')
+  expect(hub.localVm).not.toHaveBeenCalled()
+  store.remove('alice','turn','active-vm')
   const helper=store.createAgent('alice',{name:'临时任务助手',profile:'default',execution:'computer'},{createdByAgentId:first.id,createdFromRunId:randomUUID(),temporaryGoalId:randomUUID()})
   await request(app.callback()).post(`/api/app/agents/${helper.id}/local-vm/create`).send({}).expect(409)
   active=false;expect(service.allowed(id,runner.id)).toBe(false)

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

@@ -46,3 +46,38 @@ it('does not grant temporary helpers or mixed profiles a shared desktop',()=>{
     expect(()=>service.create('owner',command)).toThrow('持久隔离成员')
   }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{
+    const vm=store.createAgent('owner',{name:'虚拟机成员',execution:'computer',profile:'default'})
+    const chat=store.createAgent('owner',{name:'正在聊天',profile:'server'})
+    const archived=store.createAgent('owner',{name:'已归档',execution:'computer',profile:'default'})
+    store.put('owner','agent',archived.id,{...archived,archived:true})
+    const helper=store.createAgent('owner',{name:'临时助手',execution:'computer',profile:'default'},{createdByAgentId:vm.id,createdFromRunId:randomUUID(),temporaryGoalId:randomUUID()})
+    const remote=store.createAgent('owner',{name:'远端成员',execution:'computer',profile:'default'})
+    store.put('owner','agent',remote.id,{...remote,nodeId:randomUUID()})
+    const unaffected=[chat,archived,helper,remote].map(a=>store.require<WorkspaceAgent>('owner','agent',a.id))
+    for(const agent of unaffected)store.put('owner','turn',agent.id,{agentId:agent.id,status:'running'})
+    service.setLocalVmMode('owner','shared','runner')
+    const linked=store.require<WorkspaceAgent>('owner','agent',vm.id)
+    expect(linked.computerEnvironmentId).toBeTruthy()
+    for(const agent of unaffected)expect(store.require('owner','agent',agent.id)).toEqual(agent)
+    service.setLocalVmMode('owner','shared','runner')
+    expect(store.require('owner','agent',vm.id)).toEqual(linked)
+    // Explicit per-Agent execution changes must still guard an ordinary chat.
+    expect(()=>service.assertLocalVmIdle('owner',[chat.id])).toThrow('正在聊天')
+    for(const status of ['queued','running','waiting','uncertain','cancelling']){
+      store.put('owner','turn','active-vm',{agentId:vm.id,status})
+      expect(()=>service.setLocalVmMode('owner','per-bot','runner')).toThrow('请先停止「虚拟机成员」')
+      expect(store.require('owner','agent',vm.id)).toEqual(linked)
+    }
+    store.remove('owner','turn','active-vm')
+    // A controller acquired through another member still owns this same VM.
+    store.put('_system','computer-control','human',{owner:'owner',agentId:archived.id,environmentId:linked.computerEnvironmentId,expiresAt:Date.now()+30000})
+    expect(()=>service.setLocalVmMode('owner','per-bot','runner')).toThrow('交还')
+    store.remove('_system','computer-control','human')
+    service.setLocalVmMode('owner','per-bot','runner')
+    expect(store.require<WorkspaceAgent>('owner','agent',vm.id).computerEnvironmentId).toBeUndefined()
+    for(const agent of unaffected)expect(store.require('owner','agent',agent.id)).toEqual(agent)
+  }finally{nodes.close();store.close();rmSync(home,{recursive:true,force:true})}
+})