Quellcode durchsuchen

fix(runner): 虚拟机租约延后到真正用电脑工具时再拿

会话 create/resume 不再抢池子;computer_* / skill 首次调用才 ensureLease。池子忙或环境故障软回工具结果,避免查天气等非电脑回合整轮被 computer_busy 掐死。
Samien vor 1 Monat
Ursprung
Commit
0a3b332fb4

+ 29 - 0
src/runner/computers/computerFailures.ts

@@ -0,0 +1,29 @@
+import {HttpError} from '../../server/errors.js'
+import {ComputerError} from './container.js'
+
+/** Environment/pool failures that should become tool results, not hard run kills. */
+export const SOFT_COMPUTER_CODES = new Set([
+  'computer_busy',
+  'computer_quota',
+  'computer_pool_unavailable',
+  'computer_image_required',
+  'computer_unavailable',
+  'computer_stop_uncertain',
+  'computer_owner_mismatch',
+  'computer_spec_invalid',
+  'computer_authorization_revoked',
+  'compose_desktop_error',
+  'compose_desktop_managed',
+])
+
+export function softComputerFailure(error:unknown){
+  if(error instanceof ComputerError)return true
+  if(error instanceof HttpError)return SOFT_COMPUTER_CODES.has(error.code??'')
+  return false
+}
+
+export function needsVmLease(name:string,teamToolNames:string[]=[],skillToolNames:string[]=[]){
+  if(name.startsWith('host_'))return false
+  if(teamToolNames.includes(name))return false
+  return name.startsWith('computer_')||skillToolNames.includes(name)
+}

+ 52 - 22
src/runner/worker/gateway.ts

@@ -10,8 +10,9 @@ import type {GatewayFrame,GatewayTarget} from '../../server/workspaceGateway.js'
 import {ProfileComputerSession} from './profileSession.js'
 import {UNCONFIGURED_COMPUTER_IMAGE} from '../../shared/runner.js'
 import type {RunnerConfiguration} from '../../shared/runner.js'
-import {ContainerComputerProvider,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
+import {ContainerComputerProvider,ComputerError,COMPUTER_WORKSPACE,CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
 import {ComputerPool,type ComputerLease} from '../computers/pool.js'
+import {needsVmLease,softComputerFailure} from '../computers/computerFailures.js'
 import {ComposeComputerProvider} from '../computers/compose.js'
 import {COMPOSE_DESKTOP_IMAGE,type DesktopRelay} from '../../shared/composeDesktops.js'
 import {ComputerControls} from './control.js'
@@ -23,7 +24,7 @@ import {homedir,platform} from 'node:os'
 
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean;hermesRuntime?:boolean}
 export interface WorkerSession {agentId?:string;id:string;profile:string;ownerKey:string;environmentId:string;cwd:string;configuredCwd:string;history:any[];outcome?:'complete'|'failed'|'uncertain';vmExecution?:'profile';profileSessionId?:string;hermesRuntime?:boolean}
-interface Live {workMarker?:string;paused?:boolean;desktop?:{width:number;height:number};pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease:ComputerLease;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
+interface Live {workMarker?:string;paused?:boolean;desktop?:{width:number;height:number};pauseJob?:Promise<void>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxyHandle?:ProxyHandle;session:WorkerSession;model:WorkerModel;contextConfig?:WorkerContextConfiguration;proxyEnv?:WorkerProxyEnvironment;lease?:ComputerLease;acquiring?:Promise<ComputerLease>;controller:AbortController;worker?:HermesWorkerProcess;finishing?:Promise<void>;stopping?:Promise<void>;releasing?:Promise<void>;images:any[];running:boolean;received:boolean}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
 const TOOLS:WorkerTool[]=[
@@ -215,7 +216,6 @@ export class ComputerGateway {
       if(this.live)throw new HttpError(409,'电脑通道已有会话','computer_session_busy')
       await this.authorized()
       if(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;if(session.profileSessionId){await this.openProfile(session,params);await this.profileSession!.stop()}const resource=this.runtime.pool.status(this.meta.ownerKey).find(item=>item.environmentId===this.meta.environmentId);if(resource?.holderIds.includes(this.workId))await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
-      if(this.runtime.imageFor(this.meta)===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
       const profile=String(params.profile),resolved=this.hermesManaged?{cwd:this.runtime.pool.definition(this.meta.ownerKey,this.meta.environmentId)?.cwd??COMPUTER_WORKSPACE,configuredCwd:'.'}:await (this.meta.profileSession?this.runtime.resolveWorkspace(profile,this.controller.signal):this.runtime.resolve(profile,this.controller.signal))
       if(this.meta.hostAccess){const configured=String(resolved.configuredCwd??'.');this.hostCwd=['.','auto','cwd'].includes(configured)?homedir():hostPath(homedir(),configured)}
       await this.authorized();this.guard()
@@ -223,18 +223,10 @@ export class ComputerGateway {
       if(this.meta.profileSession)session.vmExecution='profile'
       if(this.hermesManaged)session.hermesRuntime=true
       session.cwd=this.meta.environmentId!==this.meta.agentId?(this.runtime.pool.definition(this.meta.ownerKey,this.meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd;session.configuredCwd=resolved.configuredCwd
-      await this.runtime.pool.configure(this.spec(session),()=>this.guard())
-      await this.authorized()
-      const controller=new AbortController(),lease=await this.runtime.pool.acquire(this.spec(session),this.workId,()=>{this.guard();if(controller.signal.aborted)throw new Error('cancelled')},controller.signal)
-      if(this.closed){controller.abort();await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
-      this.live={session,lease,controller,model:('model' in resolved?resolved.model:undefined) as WorkerModel,contextConfig:'contextConfig' in resolved?resolved.contextConfig as WorkerContextConfiguration:undefined,proxyEnv:'proxyEnv' in resolved?resolved.proxyEnv as WorkerProxyEnvironment:undefined,images:[],running:false,received:false,toolCalls:new Set(),journal:new Map()}
+      // Defer pool.configure/acquire until a computer_* (or skill) tool actually needs the VM.
+      const controller=new AbortController()
+      this.live={session,controller,model:('model' in resolved?resolved.model:undefined) as WorkerModel,contextConfig:'contextConfig' in resolved?resolved.contextConfig as WorkerContextConfiguration:undefined,proxyEnv:'proxyEnv' in resolved?resolved.proxyEnv as WorkerProxyEnvironment:undefined,images:[],running:false,received:false,toolCalls:new Set(),journal:new Map()}
       this.runtime.save(session);this.runtime.attachGateway(this)
-      if(this.runtime.config.network==='public-proxy'){
-        const live=this.live
-        try{live.proxyHandle=await this.runtime.acquireProxy(this.spec(session),()=>{if(!this.runtime.pool.hasHolders(this.meta.ownerKey,this.meta.environmentId))throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')})}
-        catch(error){await this.interrupt();throw error}
-      }
-      this.timer=setInterval(()=>{if(this.checking)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(this.live!.lease)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
       if(params.toolsOnly===true)return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:profile,tools_only:true}}
       if(this.meta.profileSession||this.hermesManaged){
         try{const opened=await this.openProfile(session,params);return {...opened,session_id:session.id,stored_session_id:session.id}}
@@ -276,7 +268,7 @@ export class ComputerGateway {
       const encoded=method==='image.attach_bytes'?String(params.content_base64??''):String(params.data_url??'').split(',')[1]??''
       const bytes=Buffer.from(encoded,'base64');if(bytes.length>25*1024*1024)throw new HttpError(413,'附件超过 25 MiB','attachment_too_large')
       const relative=`.yaoyao-inputs/${randomUUID()}/${name}`
-      await this.runtime.pool.use(live.lease,context=>this.runtime.provider.execute(this.spec(live.session),['python3','-c','import sys,pathlib; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',relative],{...context,input:bytes}))
+      await this.ensureLease(live);await this.runtime.pool.use(live.lease!,context=>this.runtime.provider.execute(this.spec(live.session),['python3','-c','import sys,pathlib; p=pathlib.Path(sys.argv[1]); p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(sys.stdin.buffer.read())',relative],{...context,input:bytes}))
       if(method==='image.attach_bytes'){
         const mime=String(params.mime_type??'image/png')
         if(!/^image\/(?:png|jpeg|webp|gif)$/.test(mime))throw new HttpError(400,'图片格式无效','invalid_image')
@@ -354,6 +346,24 @@ export class ComputerGateway {
   }
   installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>,workspaceMemory=false){this.team={id,catalog,call,workspaceMemory}}
   removeTeamLease(id:string){if(this.team?.id===id)this.team=undefined}
+  private ensureLease(live:Live):Promise<ComputerLease>{
+    if(live.lease)return Promise.resolve(live.lease)
+    live.acquiring??=(async()=>{
+      if(this.runtime.imageFor(this.meta)===UNCONFIGURED_COMPUTER_IMAGE)throw new HttpError(409,'请先在应用设置的本地虚拟机页面完成准备','computer_image_required')
+      await this.runtime.pool.configure(this.spec(live.session),()=>this.guard())
+      await this.authorized()
+      const lease=await this.runtime.pool.acquire(this.spec(live.session),this.workId,()=>{this.guard();if(live.controller.signal.aborted)throw new Error('cancelled')},live.controller.signal)
+      if(this.closed){await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
+      live.lease=lease
+      if(this.runtime.config.network==='public-proxy'){
+        try{live.proxyHandle=await this.runtime.acquireProxy(this.spec(live.session),()=>{if(!this.runtime.pool.hasHolders(this.meta.ownerKey,this.meta.environmentId))throw new HttpError(403,'电脑任务授权已失效','computer_authorization_revoked')})}
+        catch(error){await this.runtime.pool.release(lease).catch(()=>{});live.lease=undefined;throw error}
+      }
+      this.timer=setInterval(()=>{if(this.checking||!this.live?.lease)return;this.checking=true;void this.authorized().then(()=>this.runtime.pool.renew(this.live!.lease!)).catch(async()=>{await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect()}).finally(()=>{this.checking=false})},5000);this.timer.unref()
+      return lease
+    })().finally(()=>{live.acquiring=undefined})
+    return live.acquiring
+  }
   private async tool(live:Live,name:string,args:unknown,id:string):Promise<unknown>{
     if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')
     await this.authorized();this.guard()
@@ -362,17 +372,30 @@ export class ComputerGateway {
     try{
       const team=this.team?.catalog.find(tool=>tool.name===name)
       if(team)return await this.team!.call(team.id??name,z.record(z.string(),z.unknown()).parse(args),id)
+      if(needsVmLease(name,this.team?.catalog.map(tool=>tool.name)??[],SKILL_TOOLS.map(tool=>tool.name))){
+        try{await this.ensureLease(live)}
+        catch(error){
+          if(softComputerFailure(error)){
+            const message=error instanceof Error&&error.message?error.message:'电脑环境不可用'
+            return {error:message,code:error instanceof HttpError||error instanceof ComputerError?error.code:'computer_unavailable'}
+          }
+          throw error
+        }
+      }
+      const lease=live.lease
       if(SKILL_TOOLS.some(tool=>tool.name===name)){
+        if(!lease)throw new HttpError(409,'电脑环境尚未就绪','computer_unavailable')
         this.skillSession??=new ProfileSkillSession({
           python:this.runtime.config.python,script:this.runtime.script,hermesSource:this.runtime.config.hermesSource,hermesHome:this.runtime.config.hermesHome,
           profile:live.session.profile,ownerKey:this.meta.ownerKey,agentId:this.meta.agentId,taskId:this.workId,signal:live.controller.signal,
-          authorize:async()=>{await this.authorized();if(live.paused||live.received)throw new HttpError(410,'本轮技能工具已结束或暂停','computer_cancelled');this.runtime.pool.authorize(live.lease)},
-          execute:(argv,input)=>this.runtime.pool.use(live.lease,context=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})),
-          install:(bundle,script)=>this.runtime.pool.use(live.lease,context=>this.runtime.provider.installSkill(this.spec(live.session),bundle,script,context)),
+          authorize:async()=>{await this.authorized();if(live.paused||live.received)throw new HttpError(410,'本轮技能工具已结束或暂停','computer_cancelled');this.runtime.pool.authorize(lease)},
+          execute:(argv,input)=>this.runtime.pool.use(lease,context=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})),
+          install:(bundle,script)=>this.runtime.pool.use(lease,context=>this.runtime.provider.installSkill(this.spec(live.session),bundle,script,context)),
         })
         return await this.skillSession.call(name,args)
       }
-      return await this.runtime.pool.use(live.lease,async context=>{
+      if(!lease)throw new HttpError(409,'电脑环境尚未就绪','computer_unavailable')
+      return await this.runtime.pool.use(lease,async context=>{
         const execute=(argv:string[],input?:Buffer,lane?:'exec'|'gui',user?:'cua'|'root')=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input,...(lane?{lane}:{}),...(user?{user}:{})})
         if(this.meta.hostAccess&&name.startsWith('host_'))return hostTool(name,args,{...context,cwd:this.hostCwd})
         if((this.meta.hostAccess||this.hermesManaged)&&name==='computer_copy_file'){
@@ -458,12 +481,18 @@ export class ComputerGateway {
         }
         throw new HttpError(403,'本轮未授权该工具','computer_tool_forbidden')
       })
+    }catch(error){
+      if(softComputerFailure(error)){
+        const message=error instanceof Error&&error.message?error.message:'电脑环境不可用'
+        return {error:message,code:error instanceof HttpError||error instanceof ComputerError?error.code:'computer_unavailable'}
+      }
+      throw error
     }finally{this.event('tool.completed',{tool_id:id,name})}
   }
   private async finish(live:Live,frame:WorkerFrame){
     if(this.closed||live.paused||live.controller.signal.aborted)return
     await this.authorized()
-    this.runtime.pool.renew(live.lease)
+    if(live.lease)this.runtime.pool.renew(live.lease)
     const contextErrors:Record<string,string>={
       context_compaction_disabled:'模型上下文已超限,基础 Profile 关闭了自动压缩。请开启压缩、缩短输入或新建对话后继续。',
       context_compaction_failed:'模型上下文压缩未能完成,本轮已结束。请缩短输入、检查模型配置或新建对话后继续。',
@@ -487,6 +516,7 @@ export class ComputerGateway {
   private release(live:Live,keepRunning=false){
     clearInterval(this.timer)
     live.releasing??=(async()=>{
+      if(!live.lease){await live.proxyHandle?.release().catch(()=>{});return}
       try{await live.proxyHandle?.release();await this.runtime.pool.release(live.lease,keepRunning)}
       catch(error){await this.runtime.pool.release(live.lease).catch(()=>{});throw error}
     })()
@@ -520,7 +550,7 @@ export class ComputerGateway {
         history.push({role:'tool',name:tool.function?.name??entry?.name,tool_call_id:tool.id,content});done.add(tool.id)
       }
       live.session.history=history;this.runtime.save(live.session)
-      this.event('computer.paused',{stage:'human',generation:live.lease.generation})
+      this.event('computer.paused',{stage:'human',generation:live.lease?.generation})
     })()
     await live.pauseJob
   }
@@ -529,7 +559,7 @@ export class ComputerGateway {
     if(!live?.paused)throw new HttpError(409,'机器人没有处于接管状态','computer_not_paused')
     await live.pauseJob;await this.authorized()
     live.paused=false;live.pauseJob=undefined;live.journal.clear()
-    this.event('computer.resumed',{generation:live.lease.generation,notes})
+    this.event('computer.resumed',{generation:live.lease?.generation,notes})
     if(this.profileSession){
       await this.profileSession.bind();this.guard()
       await this.profileSession.rpc('prompt.submit',{text:`${this.rules}\n用户已交还电脑控制权。请先检查当前状态,避免重复已完成的操作。交还说明:${notes||'无额外说明'}\n${live.workMarker??''}`})

+ 20 - 0
tests/runner/computerFailures.test.ts

@@ -0,0 +1,20 @@
+import {describe,expect,it} from 'vitest'
+import {HttpError} from '../../src/server/errors.js'
+import {ComputerError} from '../../src/runner/computers/container.js'
+import {needsVmLease,softComputerFailure} from '../../src/runner/computers/computerFailures.js'
+
+describe('computerFailures',()=>{
+  it('treats pool/env codes and ComputerError as soft tool failures',()=>{
+    expect(softComputerFailure(new ComputerError('computer_busy','电脑正在使用或需要核对停止状态'))).toBe(true)
+    expect(softComputerFailure(new HttpError(409,'请先准备','computer_image_required'))).toBe(true)
+    expect(softComputerFailure(new HttpError(502,'模型失败','run_failed'))).toBe(false)
+    expect(softComputerFailure(new Error('random'))).toBe(false)
+  })
+  it('requires a VM lease only for computer_* and skill tools, not host or team tools',()=>{
+    expect(needsVmLease('computer_shell')).toBe(true)
+    expect(needsVmLease('computer_desktop_state')).toBe(true)
+    expect(needsVmLease('skills_list',[],['skills_list'])).toBe(true)
+    expect(needsVmLease('host_shell')).toBe(false)
+    expect(needsVmLease('team_ping',['team_ping'])).toBe(false)
+  })
+})