Просмотр исходного кода

fix: Bot 正常完成后按空闲策略保留虚拟机

Samien 1 месяц назад
Родитель
Сommit
768f51a5ed

+ 2 - 0
docs/local-vm.md

@@ -13,6 +13,8 @@
 
 
 虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;默认空闲 5 分钟后自动停止。应用设置 → **本地虚拟机 → 空闲自动停止** 可选择 1、5、10、15、30、60、120 分钟或 **永不自动停止**。设置保存在执行节点,适用于该节点的所有动态本地虚拟机。再次执行任务或接管时结束空闲计时,交还后重新计时;仅查看画面不会重置计时。
 虚拟机实例移除或重建均保留工作目录和浏览器资料。正常完成任务和主动交还后桌面进入空闲状态;默认空闲 5 分钟后自动停止。应用设置 → **本地虚拟机 → 空闲自动停止** 可选择 1、5、10、15、30、60、120 分钟或 **永不自动停止**。设置保存在执行节点,适用于该节点的所有动态本地虚拟机。再次执行任务或接管时结束空闲计时,交还后重新计时;仅查看画面不会重置计时。
 
 
+Bot 通过虚拟机工具执行命令或打开网页后,正常回复结束只释放本轮控制权,保留桌面、窗口及正在运行的程序,再按上述空闲策略回收;工具连接结束本身不代表关闭虚拟机。外部 Runner 也需更新才能支持这项正常收尾。
+
 “永不自动停止”仅关闭空闲计时。手动停止、取消、授权撤销、运行中的控制租约过期及执行节点关闭仍会停止旧实例,以隔离迟到操作。虚拟机沿用已保存的工作目录,Hermes 本机工作目录单独解析。旧版 Runner 需要更新后才能修改此设置;Compose 桌面的启停仍由 Compose 管理。
 “永不自动停止”仅关闭空闲计时。手动停止、取消、授权撤销、运行中的控制租约过期及执行节点关闭仍会停止旧实例,以隔离迟到操作。虚拟机沿用已保存的工作目录,Hermes 本机工作目录单独解析。旧版 Runner 需要更新后才能修改此设置;Compose 桌面的启停仍由 Compose 管理。
 
 
 ## 同一 Bot 使用本机和虚拟机
 ## 同一 Bot 使用本机和虚拟机

+ 1 - 1
src/runner/agent.ts

@@ -244,7 +244,7 @@ export class RunnerAgent {
     if(command.kind==='gateway.close'){await this.closeConnection(connectionId);return {ok:true}}
     if(command.kind==='gateway.close'){await this.closeConnection(connectionId);return {ok:true}}
     if(command.kind==='gateway.rpc') {
     if(command.kind==='gateway.rpc') {
       const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
       const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
-      const computerCommand=method==='computer.invoke'||method==='computer.transfer'
+      const computerCommand=method==='computer.invoke'||method==='computer.transfer'||method==='computer.complete'
       if(!connection||(!commands.has(method)&&!(computerCommand&&connection.gateway instanceof ComputerGateway))||!params||typeof params!=='object')throw new HttpError(403,'执行通道或命令无效','runner_command_forbidden')
       if(!connection||(!commands.has(method)&&!(computerCommand&&connection.gateway instanceof ComputerGateway))||!params||typeof params!=='object')throw new HttpError(403,'执行通道或命令无效','runner_command_forbidden')
       if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
       if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
       if(method==='session.create'||method==='session.resume'){if(!connection.cleanupOnly)this.requireProfile(params.profile)}
       if(method==='session.create'||method==='session.resume'){if(!connection.cleanupOnly)this.requireProfile(params.profile)}

+ 18 - 1
src/runner/worker/gateway.ts

@@ -291,6 +291,21 @@ export class ComputerGateway {
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
       return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile}}
     }
     }
     const live=this.live
     const live=this.live
+    if(method==='computer.complete'){
+      if(!live?.toolsOnly||params.session_id!==live.session.id)throw new HttpError(403,'完成请求不属于当前虚拟机工具会话','computer_session_forbidden')
+      // Normal Bot completion releases its holder into the idle policy. An
+      // unexpected disconnect still takes the interrupt/fencing path.
+      live.received=true
+      live.finishing??=(async()=>{
+        await Promise.allSettled([...live.toolCalls])
+        await this.authorized()
+        await this.release(live,this.runtime.retainDesktops)
+        live.session.outcome='complete';this.runtime.save(live.session)
+      })()
+      await live.finishing
+      return {status:'complete'}
+    }
+    if(live?.toolsOnly&&live.received)throw new HttpError(410,'本轮虚拟机工具会话已结束','computer_cancelled')
     if(method==='computer.transfer'){
     if(method==='computer.transfer'){
       if(!live||params.session_id!==live.session.id)throw new HttpError(403,'文件传输不属于当前电脑会话','computer_session_forbidden')
       if(!live||params.session_id!==live.session.id)throw new HttpError(403,'文件传输不属于当前电脑会话','computer_session_forbidden')
       const call=this.transferFile(live,z.record(z.string(),z.unknown()).parse(params.action))
       const call=this.transferFile(live,z.record(z.string(),z.unknown()).parse(params.action))
@@ -301,7 +316,9 @@ export class ComputerGateway {
       if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
       if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
       const name=String(params.name??'')
       const name=String(params.name??'')
       if(!TOOLS.some(tool=>tool.name===name))throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
       if(!TOOLS.some(tool=>tool.name===name))throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
-      return this.tool(live,name,params.arguments??{},typeof params.id==='string'&&params.id?params.id:randomUUID())
+      const call=this.tool(live,name,params.arguments??{},typeof params.id==='string'&&params.id?params.id:randomUUID())
+      live.toolCalls.add(call);void call.finally(()=>live.toolCalls.delete(call)).catch(()=>{})
+      return call
     }
     }
     if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
     if(!live||params.session_id!==live.session.id)throw new HttpError(403,'电脑会话不属于当前通道','computer_session_forbidden')
     if(method==='session.interrupt'||method==='session.close'){await this.interrupt();return {status:'interrupted'}}
     if(method==='session.interrupt'||method==='session.close'){await this.interrupt();return {status:'interrupted'}}

+ 10 - 0
src/server/vmComputer.ts

@@ -54,5 +54,15 @@ export class VmToolSession {
     }
     }
     return value
     return value
   }
   }
+  async complete(){
+    const gateway=this.gateway
+    if(!gateway)return
+    try{
+      await this.opening
+      this.authorize()
+      if(this.gateway!==gateway)throw new Error('虚拟环境连接已关闭')
+      await gateway.rpc('computer.complete',{session_id:this.sessionId})
+    }finally{this.close()}
+  }
   close(){const gateway=this.gateway;this.gateway=undefined;this.sessionId='';gateway?.close()}
   close(){const gateway=this.gateway;this.gateway=undefined;this.sessionId='';gateway?.close()}
 }
 }

+ 7 - 1
src/server/workspaceRuntime.ts

@@ -420,7 +420,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     })
     })
     // Register rejection immediately; setup RPCs can still be awaiting a reply.
     // Register rejection immediately; setup RPCs can still be awaiting a reply.
     void completion.catch(() => {})
     void completion.catch(() => {})
-    const finish = (error?: Error) => {
+    const finish = async (error?: Error) => {
       if (settled) return
       if (settled) return
       settled = true
       settled = true
       // End browser task authority before aborting the shared tool lease. A
       // End browser task authority before aborting the shared tool lease. A
@@ -436,6 +436,12 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       this.live.delete(key)
       this.live.delete(key)
       this.executionTurns.delete(run.id)
       this.executionTurns.delete(run.id)
       try {
       try {
+        // Retention requires the still-active work grant. Complete the VM
+        // channel before persisting terminal status or ending system resources.
+        if(!error&&vmTools)try{await vmTools.complete()}catch(failure){
+          resultMessage.serviceWarnings??=[]
+          resultMessage.serviceWarnings.push({service:'虚拟机',code:'computer_completion_failed',message:`虚拟机未能保留运行:${failure instanceof Error?failure.message:'执行节点收尾失败'}`.slice(0,1000)})
+        }
         const current = this.getWork(owner, run.id)
         const current = this.getWork(owner, run.id)
         if (current.cancelRequested && current.status === 'interrupted') {
         if (current.cancelRequested && current.status === 'interrupted') {
           resultMessage.status = 'interrupted'
           resultMessage.status = 'interrupted'

+ 4 - 1
tests/server/runner.test.ts

@@ -115,10 +115,13 @@ it('admits virtual computer calls only on a computer gateway with an owned sessi
   const command=(method:string,id=sessionId)=>({kind:'gateway.rpc',payload:{connectionId,method,params:{session_id:id,name:'computer_desktop_state',arguments:{}}}})
   const command=(method:string,id=sessionId)=>({kind:'gateway.rpc',payload:{connectionId,method,params:{session_id:id,name:'computer_desktop_state',arguments:{}}}})
   expect(await (runner as any).execute(command('computer.invoke'))).toEqual({ok:true})
   expect(await (runner as any).execute(command('computer.invoke'))).toEqual({ok:true})
   expect(await (runner as any).execute(command('computer.transfer'))).toEqual({ok:true})
   expect(await (runner as any).execute(command('computer.transfer'))).toEqual({ok:true})
+  expect(await (runner as any).execute(command('computer.complete'))).toEqual({ok:true})
   await expect((runner as any).execute(command('computer.invoke',randomUUID()))).rejects.toMatchObject({code:'runner_session_forbidden'})
   await expect((runner as any).execute(command('computer.invoke',randomUUID()))).rejects.toMatchObject({code:'runner_session_forbidden'})
+  await expect((runner as any).execute(command('computer.complete',randomUUID()))).rejects.toMatchObject({code:'runner_session_forbidden'})
   connection.gateway=Object.assign(Object.create(WorkspaceGateway.prototype),{rpc,close:()=>{}}) as ComputerGateway
   connection.gateway=Object.assign(Object.create(WorkspaceGateway.prototype),{rpc,close:()=>{}}) as ComputerGateway
   await expect((runner as any).execute(command('computer.invoke'))).rejects.toMatchObject({code:'runner_command_forbidden'})
   await expect((runner as any).execute(command('computer.invoke'))).rejects.toMatchObject({code:'runner_command_forbidden'})
-  expect(rpc).toHaveBeenCalledTimes(2)
+  await expect((runner as any).execute(command('computer.complete'))).rejects.toMatchObject({code:'runner_command_forbidden'})
+  expect(rpc).toHaveBeenCalledTimes(3)
 })
 })
 it('requires an upgraded Runner before granting mixed host and VM tools',()=>{
 it('requires an upgraded Runner before granting mixed host and VM tools',()=>{
  const id=randomUUID()
  const id=randomUUID()

+ 121 - 0
tests/server/vmToolLifecycle.test.ts

@@ -0,0 +1,121 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {DatabaseSync} from 'node:sqlite'
+import {mkdtemp,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {VmToolSession} from '../../src/server/vmComputer'
+import type {GatewayTarget} from '../../src/server/workspaceGateway'
+
+async function fixture(docker=false){
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-vm-lifecycle-')),db=new DatabaseSync(':memory:')
+  const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1:1',hermesURL:'http://127.0.0.1:1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:docker?process.env.YAOYAO_COMPUTER_IMAGE!:'sha256:'+'a'.repeat(64),python:'/unused',hermesSource:home,hermesHome:home,network:'none'}},home)
+  await runtime.ready;runtime.retainDesktops=true;runtime.pool.idleStopMinutes=0
+  const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'vm-lifecycle-fixture',hermesRuntime:true}
+  const gateways:ComputerGateway[]=[],closed:Promise<void>[]=[],calls:string[]=[]
+  let allowed=true
+  const authorize=()=>{if(!allowed)throw new Error('revoked')}
+  if(!docker){
+    vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:id,running:true,workspace:home,isolation:'container'})
+    vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
+    vi.spyOn(runtime.provider,'execute').mockResolvedValue({stdout:'vm proof',stderr:''})
+  }
+  const target={url:new URL('http://127.0.0.1:1'),client:{},session:{},runner:{id:randomUUID(),computer:true,
+    async open(onEvent,onDisconnect,scope){
+      const gateway=new ComputerGateway(runtime,meta,scope!.workId,async()=>{authorize();scope!.authorize()},async()=>({}),target)
+      gateways.push(gateway);gateway.onEvent=onEvent;gateway.onDisconnect=onDisconnect
+      await gateway.connect()
+      return {rpc:async(method,params)=>{calls.push(method);return gateway.rpc(method,params)},close:()=>{closed.push(gateway.close())}}
+    },lease:async()=>{throw new Error('unused')},
+  }} as GatewayTarget
+  const session=()=>new VmToolSession(target,'default',randomUUID(),authorize)
+  return {runtime,meta,gateways,calls,session,revoke:()=>{allowed=false},drain:()=>Promise.all(closed),state:()=>runtime.pool.status(meta.ownerKey)[0],
+    async close(){await Promise.all(gateways.map(g=>g.close().catch(()=>{})));await Promise.all(closed);await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await rm(home,{recursive:true,force:true})},
+  }
+}
+
+it('keeps a completed Bot VM idle across tool-channel disconnection and the next turn',async()=>{
+  const f=await fixture()
+  try{
+    const first=f.session();await first.call('computer_shell',{command:'pwd'});await first.complete();await f.drain()
+    expect(f.state()).toMatchObject({status:'idle',holderIds:[]})
+    await f.runtime.pool.expire();expect(f.state()?.status).toBe('idle')
+    expect(f.runtime.provider.stop).not.toHaveBeenCalled()
+    const ended=f.gateways[0]!
+    await expect(ended.rpc('computer.invoke',{session_id:'old',name:'computer_shell'})).rejects.toMatchObject({code:'computer_cancelled'})
+    const second=f.session();await second.call('computer_shell',{command:'pwd'});await second.complete();await f.drain()
+    expect(f.state()?.status).toBe('idle');expect(f.runtime.provider.stop).not.toHaveBeenCalled()
+    f.runtime.pool.idleStopMinutes=1
+    const clock=vi.spyOn(f.runtime.pool,'now').mockReturnValue(Date.now()+61000)
+    try{await f.runtime.pool.expire();expect(f.state()?.status).toBe('free')}finally{clock.mockRestore()}
+  }finally{await f.close()}
+})
+
+it.each(['disconnect','revoked','interrupt'] as const)('still stops the last VM holder on %s with idle stopping disabled',async reason=>{
+  const f=await fixture()
+  try{
+    const session=f.session();await session.call('computer_shell',{command:'pwd'})
+    if(reason==='revoked'){f.revoke();await expect(session.complete()).rejects.toThrow('revoked')}
+    else if(reason==='interrupt')await f.gateways[0]!.interrupt()
+    else session.close()
+    await f.drain();expect(f.state()?.status).toBe('free')
+    expect(f.runtime.provider.stop).toHaveBeenCalled()
+  }finally{await f.close()}
+})
+
+it('waits for an in-flight command and rejects new work while completing',async()=>{
+  const f=await fixture()
+  let release!:()=>void
+  try{
+    const gate=new Promise<void>(done=>{release=done}),session=f.session()
+    vi.mocked(f.runtime.provider.execute).mockImplementation(async()=>{await gate;return {stdout:'finished',stderr:''}})
+    const command=session.call('computer_shell',{command:'slow'})
+    await vi.waitFor(()=>expect(f.runtime.provider.execute).toHaveBeenCalled())
+    const completion=session.complete()
+    await vi.waitFor(()=>expect(f.calls).toContain('computer.complete'))
+    await expect(session.call('computer_shell',{command:'late'})).rejects.toMatchObject({code:'computer_cancelled'})
+    expect(f.state()?.status).toBe('active');expect(f.runtime.provider.stop).not.toHaveBeenCalled()
+    release();await command;await completion;await f.drain()
+    expect(f.state()?.status).toBe('idle')
+  }finally{release?.();await f.close()}
+})
+
+it('releases only its own holder while another Bot still uses the shared VM',async()=>{
+  const f=await fixture()
+  try{
+    const first=f.session(),second=f.session()
+    await first.call('computer_shell',{command:'pwd'});await second.call('computer_shell',{command:'pwd'})
+    await first.complete();await f.drain()
+    expect(f.state()).toMatchObject({status:'active',holderIds:[f.gateways[1]!.workId]})
+    await second.complete();await f.drain()
+    expect(f.state()?.status).toBe('idle');expect(f.runtime.provider.stop).not.toHaveBeenCalled()
+  }finally{await f.close()}
+})
+
+it('does not start an unused VM merely to complete a turn',async()=>{
+  const f=await fixture()
+  try{await f.session().complete();expect(f.calls).toEqual([]);expect(f.state()).toBeUndefined()}finally{await f.close()}
+})
+
+it.skipIf(!process.env.YAOYAO_COMPUTER_IMAGE)('retains the real Docker container and running processes after Bot completion',async()=>{
+  const f=await fixture(true)
+  try{
+    const first=f.session()
+    await first.call('computer_shell',{command:'nohup sleep 600 >/tmp/yaoyao-retain.log 2>&1 </dev/null & echo $! > retain.pid'})
+    const spec=f.gateways[0]!.specification!,before=await f.runtime.provider.inspect(spec)
+    expect(before?.running).toBe(true)
+    await first.complete();await f.drain();await f.runtime.pool.expire()
+    expect(f.state()?.status).toBe('idle')
+    expect(await f.runtime.provider.inspect(spec)).toMatchObject({containerId:before!.containerId,running:true})
+    const second=f.session(),result=await second.call('computer_shell',{command:'kill -0 "$(cat retain.pid)" && printf process-survived'})
+    expect(JSON.stringify(result)).toContain('process-survived')
+    await second.complete();await f.drain()
+    expect(await f.runtime.provider.inspect(spec)).toMatchObject({containerId:before!.containerId,running:true})
+    f.runtime.pool.idleStopMinutes=1
+    const clock=vi.spyOn(f.runtime.pool,'now').mockReturnValue(Date.now()+61000)
+    try{await f.runtime.pool.expire()}finally{clock.mockRestore()}
+    expect(await f.runtime.provider.inspect(spec)).toBeUndefined()
+  }finally{await f.close()}
+},120000)

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

@@ -2089,15 +2089,20 @@ it.each(['none','server','virtual'] as const)('dispatches browser and VM tools w
    expect((await call('computer_shell',{command:'pwd'})).isError).not.toBe(true)
    expect((await call('computer_shell',{command:'pwd'})).isError).not.toBe(true)
    saveHostTools(home,{vm:false})
    saveHostTools(home,{vm:false})
    expect((await call('computer_shell',{command:'pwd'})).isError).toBe(true)
    expect((await call('computer_shell',{command:'pwd'})).isError).toBe(true)
+   saveHostTools(home,{vm:true})
   }catch(error){integrationError=error}
   }catch(error){integrationError=error}
   socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'回归完成',status:'complete'}}}))
   socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'回归完成',status:'complete'}}}))
  })()}
  })()}
+ let statusAtVmCompletion:string|undefined
+ observeGatewayRequest=frame=>{if(frame.method==='computer.complete')statusAtVmCompletion=store.list<any>(owner,'turn').find(work=>work.agentId===bot.id)?.status}
  await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'打开网页,然后在虚拟机执行 pwd'}).id)
  await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'打开网页,然后在虚拟机执行 pwd'}).id)
  if(integrationError)throw integrationError
  if(integrationError)throw integrationError
  expect(browserOpen).toHaveBeenCalledOnce();expect(browserCall).toHaveBeenCalledWith('managed_browser_open',{url:'https://example.com/'},expect.any(String))
  expect(browserOpen).toHaveBeenCalledOnce();expect(browserCall).toHaveBeenCalledWith('managed_browser_open',{url:'https://example.com/'},expect.any(String))
  expect(vmTarget).toHaveBeenCalledWith(owner,expect.objectContaining({id:bot.id,execution:'computer'}))
  expect(vmTarget).toHaveBeenCalledWith(owner,expect.objectContaining({id:bot.id,execution:'computer'}))
  expect(requests.filter(call=>call.method==='computer.invoke')).toHaveLength(1)
  expect(requests.filter(call=>call.method==='computer.invoke')).toHaveLength(1)
  expect(requests.find(call=>call.method==='computer.invoke')?.params).toMatchObject({name:'computer_shell',arguments:{command:'pwd'}})
  expect(requests.find(call=>call.method==='computer.invoke')?.params).toMatchObject({name:'computer_shell',arguments:{command:'pwd'}})
+ expect(requests.filter(call=>call.method==='computer.complete')).toHaveLength(1)
+ expect(statusAtVmCompletion).toBe('running')
  const saved=store.list<any>(owner,'binding')[0]
  const saved=store.list<any>(owner,'binding')[0]
  store.put(owner,'binding',saved.id,{...saved,executionPolicyVersion:undefined})
  store.put(owner,'binding',saved.id,{...saved,executionPolicyVersion:undefined})
  reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))
  reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))