Quellcode durchsuchen

feat(computer): 共享桌面增加执行车道,命令并行而桌面操作交替

- 电脑池支持多持有者共存,共享桌面不再按整轮排队
- 命令与文件操作并发执行(最多 8 路),桌面鼠标键盘操作串行
- 启动、停止、换镜像等生命周期操作先排空所有操作再执行
- 任一轮次失败或取消只作废自身执行权,最后持有者离开后按空闲策略停止
- 人工接管时暂停桌面上的机器人,交还后继续原会话
- 同步更新本地虚拟机文档、设置面板与电脑面板说明及相关测试
Samien vor 1 Monat
Ursprung
Commit
c51989147a

+ 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 共用桌面、工作文件和浏览器资料,新建成员会继承当前共享策略。临时任务助手继续使用任务隔离环境,完成后单独清理,不会删除持久共享工作区。切换共享方式或重建本机执行节点时,服务会自动把成员归并到当前唯一共享组,并停止不再使用的旧桌面,避免旧实例占用运行数量上限。共享桌面上多个机器人的轮次并行执行:命令与文件操作并发运行(执行车道,最多 8 路同时),桌面鼠标键盘操作交替进行(桌面车道,一次一个);启动、停止、换镜像等生命周期操作会先排空所有操作再执行。任一机器人的轮次失败只作废自己的执行权,最后一个使用者离开后才按空闲策略停止桌面。人工接管时暂停该桌面上的所有机器人,接管者与桌面上的其他持有者一样获得自己的执行权;交还后机器人继续原会话。
 4. 打开 Agent 聊天,点击右上角 **电脑**。右侧电脑面板中选择 **本地虚拟机**,在 **虚拟机镜像** 中选择已准备的镜像,再创建桌面。独立桌面可分别选择不同镜像并同时运行;同一个共享桌面的所有成员只能使用这台桌面的一个镜像。
 5. 右侧显示真实画面及运行状态,可停止或重建虚拟机;点击预览或 **打开桌面** 后接管交互。
 6. **打开双桌面** 在主工作区显示两台虚拟机,保留聊天侧栏。进入时仅查看,不会创建/启动机器;切换操作对象时先交还前一台的控制权。

+ 1 - 1
src/client/components/app/LocalVmSettingsPanel.vue

@@ -48,7 +48,7 @@ onMounted(cycle);onBeforeUnmount(()=>{closed=true;clearTimeout(timer)})
   <article v-if="state?.fixedCapacity"><h3>Compose 共享桌面</h3><p>已配置 {{state.maxInstances}} 台桌面,数量由 Compose 固定。请在机器人聊天的电脑面板选择已有桌面。</p><div v-for="desktop in state.desktops" :key="desktop.id" class="instance"><strong>{{desktop.name}} · {{desktop.imageKey==='cursor'?'Cursor Universal':'标准桌面'}}</strong><span>{{desktop.ready?'已就绪':desktop.online?'正在启动':'未连接'}}</span></div></article>
   <article v-else-if="state"><h3>隔离方式</h3><p>按机器人创建独立桌面,或让可信的机器人共用一个桌面。</p>
    <div class="segmented" role="group" aria-label="虚拟机隔离方式"><button :aria-pressed="state?.mode==='shared'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('shared')">共享虚拟机</button><button :aria-pressed="state?.mode==='per-bot'" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @click="policy('per-bot')">每个机器人独立</button></div>
-   <p class="sharing-explanation">共享指多个机器人操作同一台虚拟机,共用桌面和工作文件;与可同时运行的虚拟机数量无关。</p>
+   <p class="sharing-explanation">共享指多个机器人同时使用同一台虚拟机:命令与文件操作并行执行,桌面操作交替进行,共用桌面和工作文件。</p>
    <label class="row">虚拟机运行数量上限<select aria-label="虚拟机数量上限" :value="state?.maxInstances??2" :disabled="!state?.configured||!!state?.setupRequired||acting||state.busy" @change="policy(state!.mode,Number(($event.target as HTMLSelectElement).value))"><option v-for="n in [1,2,3,4]" :key="n" :value="n">{{n}}</option></select></label>
   </article>
   <article v-if="state&&!state.fixedCapacity"><h3>空闲自动停止</h3><p>从任务结束或交还控制权后开始计时,适用于该执行节点上的所有本地虚拟机。</p>

+ 1 - 1
src/client/components/workspace/ComputerPanel.vue

@@ -119,7 +119,7 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
       <p v-if="error||state.error" class="computer-error" role="alert">{{ error||state.error }}</p>
       <footer v-if="controlsOpen||(!embedded&&!ticket)">
         <p v-if="state.backend==='grok'">多个机器人共享同一台 Grok Bot 云端电脑、工作文件与浏览器资料。</p>
-        <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
+        <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,可同时执行命令与文件操作,桌面操作交替进行;人工接管时机器人暂停等待。</p>
         <p v-if="!ticket">查看不会输入任何内容。接管后机器人会暂停,已有操作结束后才允许人工操作。</p>
         <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">

+ 1 - 2
src/runner/computers/compose.ts

@@ -1,7 +1,6 @@
 import {randomUUID} from 'node:crypto'
 import {ContainerComputerProvider,ComputerError,type ComputerSpecification,type ComputerState,type CommandResult,type SkillInstallation} from './container.js'
 import type {DesktopRelay} from '../../shared/composeDesktops.js'
-
 /** Uses only the fixed desktops declared by Compose; never calls a container engine. */
 export class ComposeComputerProvider extends ContainerComputerProvider {
   readonly fixedCapacity=true
@@ -34,7 +33,7 @@ export class ComposeComputerProvider extends ContainerComputerProvider {
   async stop(spec:ComputerSpecification){await this.releaseFence(spec)}
   async remove(spec:ComputerSpecification){await this.stop(spec)}
   async deleteWorkspace(_spec:ComputerSpecification){} // Compose owns the named volume.
-  async execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult>{
+  async execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:string;mayFence?():boolean}):Promise<CommandResult>{
     options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
     const lease=this.leases.get(spec.id);if(!lease)throw new ComputerError('computer_lease_stale','需要当前共享桌面的控制权')
     const result=await this.relay(spec.id,'execute',{ownerKey:spec.ownerKey,...lease,argv,cwd:spec.cwd??'/home/cua/workspace',timeout:options.timeout??30000,...(options.input?{input:options.input.toString('base64')}:{})})

+ 96 - 40
src/runner/computers/container.ts

@@ -34,6 +34,10 @@ const schema=z.object({
 export type ComputerSpecification=z.input<typeof schema>
 type Spec=z.output<typeof schema>
 export interface ComputerState {id:string;containerId:string;running:boolean;workspace:string;isolation:'container'|'vm'}
+/** Execution lanes for one environment: shell and file operations run as
+ * concurrent readers, desktop driver input is serialized, and lifecycle
+ * operations (start/stop/reconfigure) drain every reader first. */
+export type ComputerLane='exec'|'gui'
 export interface ComputerProvider {
   releaseFence?(spec:ComputerSpecification,keepRunning?:boolean):Promise<void>
   renewFence?(spec:ComputerSpecification):Promise<void>
@@ -43,9 +47,54 @@ export interface ComputerProvider {
   ensure(spec:ComputerSpecification,authorize:()=>void):Promise<ComputerState>
   stop(spec:ComputerSpecification):Promise<void>
   remove(spec:ComputerSpecification):Promise<void>
-  execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult>
+  execute(spec:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:ComputerLane;mayFence?():boolean}):Promise<CommandResult>
   health(spec:ComputerSpecification,authorize:()=>void):Promise<unknown>
 }
+class Semaphore {
+  private active=0
+  private readonly queue:Array<()=>void>=[]
+  constructor(readonly max:number) {}
+  acquire():Promise<()=>void> {
+    const admit=()=>{this.active++;let released=false;return ()=>{if(released)return;released=true;this.release()}}
+    if(this.active<this.max)return Promise.resolve(admit())
+    return new Promise(resolve=>this.queue.push(()=>resolve(admit())))
+  }
+  private release(){this.active--;this.queue.shift()?.()}
+}
+class Lane {
+  private active=0
+  private writer=false
+  private readonly queue:Array<{writer:boolean;admit:()=>void}>=[]
+  readonly exec=new Semaphore(8)
+  readonly gui=new Semaphore(1)
+  async read<T>(action:()=>Promise<T>,slot:Semaphore=this.exec):Promise<T> {
+    await this.enter(false)
+    const releaseSlot=await slot.acquire()
+    try { return await action() }
+    finally { releaseSlot(); this.leave() }
+  }
+  async write<T>(action:()=>Promise<T>):Promise<T> {
+    await this.enter(true)
+    try { return await action() }
+    finally { this.leave() }
+  }
+  private enter(writer:boolean):Promise<void> {
+    // Writer preference: once a lifecycle operation queues, new operations wait behind it.
+    const blocked=writer?this.writer||this.active>0:this.writer||this.queue.some(item=>item.writer)
+    if(!blocked){this.writer=writer;this.active++;return Promise.resolve()}
+    return new Promise<void>(admit=>this.queue.push({writer,admit:()=>{this.writer=writer;this.active++;admit()}}))
+  }
+  private leave(){
+    this.active--
+    if(this.active>0)return
+    this.writer=false
+    while(this.queue.length){
+      const head=this.queue[0]!
+      if(head.writer){if(this.active===0){this.queue.shift();head.admit()}break}
+      this.queue.shift();head.admit()
+    }
+  }
+}
 export class ComputerError extends Error { constructor(readonly code:string,message:string){super(message)} }
 const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex')
 const capabilities=(values:unknown)=>Array.isArray(values)?values.map(value=>String(value).toUpperCase().replace(/^CAP_/, '')).sort():[]
@@ -54,12 +103,13 @@ const capabilities=(values:unknown)=>Array.isArray(values)?values.map(value=>Str
  * process execution fall back to the host. Remote hosts run their own Runner. */
 export class ContainerComputerProvider implements ComputerProvider {
   readonly fixedCapacity:boolean=false
-  private queue=new Map<string,Promise<unknown>>()
+  private lanes=new Map<string,Lane>()
   readonly capabilities={isolation:'container',shell:true,desktop:true,persistentWorkspace:true,sharedDesktop:false,snapshots:false,network:'none'} as const
   constructor(readonly runtime:'docker'|'podman',readonly runnerId:string,readonly home:string,readonly run:ContainerCommand=command,readonly environment:NodeJS.ProcessEnv=process.env) {
     z.string().uuid().parse(runnerId)
     if(!['docker','podman'].includes(runtime))throw new ComputerError('runtime_unsupported','电脑运行时不受支持')
   }
+  private lane(id:string):Lane{let lane=this.lanes.get(id);if(!lane){lane=new Lane();this.lanes.set(id,lane)}return lane}
   async verifyRuntime() {
     if(this.runtime==='docker') {
       const endpoint=this.environment.DOCKER_HOST||JSON.parse((await this.run('docker',['context','inspect','--format','{{json .Endpoints.docker.Host}}'])).stdout)
@@ -77,12 +127,6 @@ export class ContainerComputerProvider implements ComputerProvider {
   }
   private name(spec:Spec){return `yaoyao-computer-${digest([this.runnerId,spec.id]).slice(0,24)}`}
   private labels(spec:Spec){return {[managedLabel]:spec.id,[runnerLabel]:this.runnerId,[ownerLabel]:digest(spec.ownerKey),[specLabel]:digest(spec)}}
-  private serial<T>(spec:Spec,action:()=>Promise<T>):Promise<T> {
-    const result=(this.queue.get(spec.id)??Promise.resolve()).catch(()=>{}).then(action)
-    this.queue.set(spec.id,result)
-    void result.finally(()=>{if(this.queue.get(spec.id)===result)this.queue.delete(spec.id)}).catch(()=>{})
-    return result
-  }
   private async workspace(spec:Spec) {
     const base=resolve(this.home,'computer-workspaces')
     await mkdir(base,{recursive:true,mode:0o700})
@@ -161,7 +205,7 @@ export class ContainerComputerProvider implements ComputerProvider {
   }
   async ensure(value:ComputerSpecification,authorize:()=>void):Promise<ComputerState> {
     const spec=this.validateSpecification(value)
-    return this.serial(spec,async()=>{
+    return this.lane(spec.id).write(async()=>{
       authorize();await this.verifyRuntime();authorize()
       const workspace=await this.workspace(spec)
       let detail=await this.migratedContainer(spec,await this.inspectRaw(this.name(spec)),workspace)
@@ -195,11 +239,11 @@ export class ContainerComputerProvider implements ComputerProvider {
   }
   async stop(value:ComputerSpecification):Promise<void> {
     const spec=this.validateSpecification(value)
-    return this.serial(spec,async()=>{await this.verifyRuntime();const detail=await this.inspectRaw(this.name(spec));if(detail){this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)}})
+    return this.lane(spec.id).write(async()=>{await this.verifyRuntime();const detail=await this.inspectRaw(this.name(spec));if(detail){this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)}})
   }
   async remove(value:ComputerSpecification):Promise<void> {
     const spec=this.validateSpecification(value)
-    return this.serial(spec,async()=>{
+    return this.lane(spec.id).write(async()=>{
       await this.verifyRuntime()
       const detail=await this.inspectRaw(this.name(spec));if(!detail)return
       this.assertOwned(spec,detail);await this.stopOwned(spec,detail.Id)
@@ -207,10 +251,10 @@ export class ContainerComputerProvider implements ComputerProvider {
       // Persistent workspace deletion is a separate, explicit operation.
     })
   }
-  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer}):Promise<CommandResult> {
+  async execute(value:ComputerSpecification,argv:string[],options:{authorize():void;signal?:AbortSignal;timeout?:number;input?:Buffer;lane?:ComputerLane;mayFence?():boolean}):Promise<CommandResult> {
     const spec=this.validateSpecification(value)
     if(!argv.length||argv.length>64||argv.some(arg=>typeof arg!=='string'||arg.includes('\0'))||argv.join('').length>65536)throw new ComputerError('computer_command_invalid','电脑命令无效或过长')
-    return this.serial(spec,async()=>{
+    return this.lane(spec.id).read(async()=>{
       options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
       const state=await this.inspect(spec)
       if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
@@ -220,15 +264,20 @@ export class ContainerComputerProvider implements ComputerProvider {
         options.authorize();return result
       }catch(error){
         // Killing a docker exec client alone cannot prove its guest process stopped.
-        // A cancelled or unacknowledged operation closes this private environment.
-        if(options.signal?.aborted||(error as any).killed||typeof (error as any).code!=='number')await this.stopOwned(spec,state.containerId)
+        // A cancelled or unacknowledged operation closes this private environment —
+        // unless other holders still share it, in which case only this operation fails.
+        if(options.signal?.aborted||(error as any).killed||typeof (error as any).code!=='number'){
+          if(options.mayFence===undefined||options.mayFence())this.fence(spec,state.containerId)
+        }
         throw error
       }
-    })
+    },options.lane==='gui'?this.lane(spec.id).gui:undefined)
   }
+  /** Escalates to the lifecycle lane so the stop never runs under live operations. */
+  private fence(spec:Spec,id:string){void this.lane(spec.id).write(async()=>{await this.stopOwned(spec,id).catch(()=>{})}).catch(()=>{})}
   async installSkill(value:ComputerSpecification,bundle:SkillInstallation,script:string,options:{authorize():void;signal?:AbortSignal}):Promise<{path:string;revision:string}> {
     const spec=this.validateSpecification(value)
-    return this.serial(spec,async()=>{
+    return this.lane(spec.id).read(async()=>{
       options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
       const state=await this.inspect(spec)
       if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
@@ -238,26 +287,31 @@ export class ContainerComputerProvider implements ComputerProvider {
     })
   }
   async capture(value:ComputerSpecification,authorize:()=>void):Promise<{data:string;width:number;height:number;capturedAt:number}> {
-    const spec=this.validateSpecification(value),state=await this.inspect(spec)
-    authorize();if(!state?.running)throw new ComputerError('computer_not_running','电脑尚未运行')
-    const file=`/tmp/yaoyao-view-${randomBytes(12).toString('hex')}.png`
-    const exec=(args:string[])=>this.run(this.runtime,['exec','--user','1000:1000','--',state.containerId,...args],{timeout:10000})
-    try{
-      await exec([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',file]);authorize()
-      const data=(await exec(['base64','-w0',file])).stdout.trim(),bytes=Buffer.from(data,'base64')
-      if(bytes.length<24||bytes.subarray(1,4).toString()!=='PNG')throw new ComputerError('computer_frame_invalid','电脑画面无效')
-      authorize();return {data,width:bytes.readUInt32BE(16),height:bytes.readUInt32BE(20),capturedAt:Date.now()}
-    }finally{await exec(['rm','-f','--',file]).catch(()=>{})}
+    const spec=this.validateSpecification(value)
+    return this.lane(spec.id).read(async()=>{
+      const state=await this.inspect(spec)
+      authorize();if(!state?.running)throw new ComputerError('computer_not_running','电脑尚未运行')
+      const file=`/tmp/yaoyao-view-${randomBytes(12).toString('hex')}.png`
+      const exec=(args:string[])=>this.run(this.runtime,['exec','--user','1000:1000','--',state.containerId,...args],{timeout:10000})
+      try{
+        await exec([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',file]);authorize()
+        const data=(await exec(['base64','-w0',file])).stdout.trim(),bytes=Buffer.from(data,'base64')
+        if(bytes.length<24||bytes.subarray(1,4).toString()!=='PNG')throw new ComputerError('computer_frame_invalid','电脑画面无效')
+        authorize();return {data,width:bytes.readUInt32BE(16),height:bytes.readUInt32BE(20),capturedAt:Date.now()}
+      }finally{await exec(['rm','-f','--',file]).catch(()=>{})}
+    },this.lane(spec.id).gui)
   }
   async deleteWorkspace(value:ComputerSpecification):Promise<void>{
     const spec=this.validateSpecification(value)
-    if(await this.inspectRaw(this.name(spec)))throw new ComputerError('computer_busy','删除工作区前必须移除电脑实例')
-    const base=await realpath(resolve(this.home,'computer-workspaces')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
-    if(!base)return
-    const path=join(base,spec.id),info=await lstat(path).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
-    if(!info)return
-    if(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','拒绝删除非预期的电脑工作区')
-    await rm(path,{recursive:true})
+    return this.lane(spec.id).write(async()=>{
+      if(await this.inspectRaw(this.name(spec)))throw new ComputerError('computer_busy','删除工作区前必须移除电脑实例')
+      const base=await realpath(resolve(this.home,'computer-workspaces')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+      if(!base)return
+      const path=join(base,spec.id),info=await lstat(path).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+      if(!info)return
+      if(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','拒绝删除非预期的电脑工作区')
+      await rm(path,{recursive:true})
+    })
   }
   async openPipe(value:ComputerSpecification,argv:string[],authorize:()=>void){
     const spec=this.validateSpecification(value)
@@ -268,13 +322,15 @@ export class ContainerComputerProvider implements ComputerProvider {
   async configureNetwork(value:ComputerSpecification,authorize:()=>void){
     const spec=this.validateSpecification(value)
     if(spec.network!=='public-proxy')return
-    authorize();const state=await this.inspect(spec);authorize()
-    if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
-    const script='import pathlib,json; chrome={"ProxyMode":"fixed_servers","ProxyServer":"http://127.0.0.1:3128","ProxyBypassList":"localhost;127.0.0.1;[::1]","QuicAllowed":False}; firefox={"policies":{"Proxy":{"Mode":"manual","HTTPProxy":"127.0.0.1:3128","SSLProxy":"127.0.0.1:3128","Passthrough":"localhost,127.0.0.1,::1","Locked":True},"DNSOverHTTPS":{"Enabled":False,"Locked":True}}}; paths=[("/etc/chromium/policies/managed/yaoyao.json",chrome),("/etc/opt/chrome/policies/managed/yaoyao.json",chrome),("/usr/lib/firefox/distribution/policies.json",firefox),("/usr/lib/firefox-esr/distribution/policies.json",firefox),("/opt/firefox/distribution/policies.json",firefox)]; [(pathlib.Path(p).parent.mkdir(parents=True,exist_ok=True),pathlib.Path(p).write_text(json.dumps(v))) for p,v in paths]'
-    await this.run(this.runtime,['exec','--user','0','--',state.containerId,'python3','-c',script])
-    authorize()
+    return this.lane(spec.id).read(async()=>{
+      authorize();const state=await this.inspect(spec);authorize()
+      if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+      const script='import pathlib,json; chrome={"ProxyMode":"fixed_servers","ProxyServer":"http://127.0.0.1:3128","ProxyBypassList":"localhost;127.0.0.1;[::1]","QuicAllowed":False}; firefox={"policies":{"Proxy":{"Mode":"manual","HTTPProxy":"http://127.0.0.1:3128","SSLProxy":"http://127.0.0.1:3128","Passthrough":"localhost,127.0.0.1,::1","Locked":True},"DNSOverHTTPS":{"Enabled":False,"Locked":True}}}; paths=[("/etc/chromium/policies/managed/yaoyao.json",chrome),("/etc/opt/chrome/policies/managed/yaoyao.json",chrome),("/usr/lib/firefox/policies/managed/yaoyao.json",firefox),("/usr/lib/firefox-esr/distribution/policies.json",firefox),("/opt/firefox/distribution/policies.json",firefox)]; [(pathlib.Path(p).parent.mkdir(parents=True,exist_ok=True),pathlib.Path(p).write_text(json.dumps(v))) for p,v in paths]'
+      await this.run(this.runtime,['exec','--user','0','--',state.containerId,'python3','-c',script])
+      authorize()
+    })
   }
   async health(value:ComputerSpecification,authorize:()=>void):Promise<unknown> {
-    return JSON.parse((await this.execute(value,[CUA_DRIVER,'call','health_report','{}','--socket',CUA_SOCKET],{authorize})).stdout)
+    return JSON.parse((await this.execute(value,[CUA_DRIVER,'call','health_report','{}','--socket',CUA_SOCKET],{authorize,lane:'gui'})).stdout)
   }
 }

+ 93 - 66
src/runner/computers/pool.ts

@@ -6,14 +6,17 @@ import {DEFAULT_VM_IDLE_STOP_MINUTES} from '../../shared/localVm.js'
 
 export interface ComputerLease {id:string;environmentId:string;ownerKey:string;holderId:string;generation:number}
 type Status='free'|'idle'|'preparing'|'active'|'stopping'|'uncertain'
-interface Entry {spec:ComputerSpecification;generation:number;status:Status;lease?:ComputerLease;expiresAt:number;updatedAt:number}
+interface Entry {spec:ComputerSpecification;generation:number;status:Status;holders:Map<string,Active>;expiresAt:number;updatedAt:number}
 interface Active {lease:ComputerLease;authorize():void;controller:AbortController;operations:Set<Promise<unknown>>;detach():void}
 
-/** Durable exclusive use. A generation is invalidated before cancellation; no
- * successor is admitted until the old private environment is confirmed stopped. */
+/** Durable lifecycle fencing with shared holders. Turns hold the same
+ * environment concurrently so sharing a desktop never queues whole turns;
+ * starting, stopping and reconfiguring remain exclusive and drain everyone.
+ * A holder that fails or cancels only fences itself — the container is
+ * stopped once the last holder leaves. */
 export class ComputerPool {
   idleStopMinutes = DEFAULT_VM_IDLE_STOP_MINUTES
-  private active=new Map<string,Active>()
+  private entries=new Map<string,Entry>()
   private changing=new Map<string,Promise<unknown>>()
   private ready=false
   private closing=false
@@ -27,9 +30,16 @@ export class ComputerPool {
     if(![limits.concurrent,limits.environments,limits.ttlMs].every(Number.isSafeInteger)||limits.concurrent<1||limits.concurrent>8||limits.environments<limits.concurrent||limits.environments>128||limits.ttlMs<1000||limits.ttlMs>120000)throw new Error('电脑资源配额无效')
     db.exec('CREATE TABLE IF NOT EXISTS computer_environments(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
   }
-  private rows():Entry[]{return (this.db.prepare('SELECT value FROM computer_environments').all() as {value:string}[]).map(row=>JSON.parse(row.value))}
-  private get(id:string):Entry|undefined {const row=this.db.prepare('SELECT value FROM computer_environments WHERE id=?').get(id) as {value:string}|undefined;return row?JSON.parse(row.value):undefined}
-  private save(entry:Entry){entry.updatedAt=this.now();this.db.prepare('INSERT INTO computer_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(entry.spec.id,JSON.stringify(entry))}
+  private rows():Entry[]{return [...this.entries.values()]}
+  private get(id:string):Entry|undefined {return this.entries.get(id)}
+  /** Holders are runtime-only state; the durable row keeps the lifecycle. */
+  private save(entry:Entry){entry.updatedAt=this.now();this.db.prepare('INSERT INTO computer_environments VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(entry.spec.id,JSON.stringify({spec:entry.spec,generation:entry.generation,status:entry.status,expiresAt:entry.expiresAt,updatedAt:entry.updatedAt}))}
+  private load():Entry[] {
+    const rows=this.db.prepare('SELECT id,value FROM computer_environments').all() as {id:string;value:string}[]
+    const entries=rows.map(row=>{const parsed=JSON.parse(row.value);return {spec:parsed.spec,generation:parsed.generation,status:parsed.status,holders:new Map(),expiresAt:parsed.expiresAt,updatedAt:parsed.updatedAt} as Entry})
+    for(const entry of entries)this.entries.set(entry.spec.id,entry)
+    return entries
+  }
   private serial<T>(id:string,action:()=>Promise<T>):Promise<T>{
     const pending=(this.changing.get(id)??Promise.resolve()).catch(()=>{}).then(action)
     this.changing.set(id,pending);void pending.finally(()=>{if(this.changing.get(id)===pending)this.changing.delete(id)}).catch(()=>{})
@@ -38,18 +48,19 @@ export class ComputerPool {
   /** Must run under the Runner's OS instance lock before accepting commands. */
   async recover():Promise<void>{
     if(this.ready)throw new ComputerError('computer_pool_active','运行中的资源池不能重新执行启动恢复')
-    for(const entry of this.rows())if(entry.status!=='free')await this.serial(entry.spec.id,()=>this.stopEntry(entry)).catch(()=>{})
+    for(const entry of this.load())if(entry.status!=='free')await this.serial(entry.spec.id,()=>this.stopEntry(entry)).catch(()=>{})
     this.ready=true
   }
-  status(ownerKey:string){return this.rows().filter(row=>row.spec.ownerKey===ownerKey).map(row=>({environmentId:row.spec.id,status:row.status,generation:row.generation,holderId:row.lease?.holderId,expiresAt:row.expiresAt}))}
+  status(ownerKey:string){return this.rows().filter(row=>row.spec.ownerKey===ownerKey).map(row=>({environmentId:row.spec.id,status:row.status,generation:row.generation,holderIds:[...row.holders.values()].map(active=>active.lease.holderId),expiresAt:row.expiresAt}))}
+  hasHolders(ownerKey:string,environmentId:string):boolean{const entry=this.get(environmentId);return !!entry&&entry.spec.ownerKey===ownerKey&&entry.status==='active'&&entry.holders.size>0}
   private require(lease:ComputerLease,activeOnly=true):Entry {
-    const entry=this.get(lease.environmentId)
-    if(!entry||entry.spec.ownerKey!==lease.ownerKey||entry.lease?.id!==lease.id||entry.lease.holderId!==lease.holderId||entry.generation!==lease.generation||(activeOnly&&entry.status!=='active'))throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')
+    const entry=this.get(lease.environmentId),active=entry?.holders.get(lease.id)
+    if(!entry||entry.spec.ownerKey!==lease.ownerKey||!active||active.lease.holderId!==lease.holderId||active.lease.generation!==lease.generation||entry.generation!==lease.generation||(activeOnly&&entry.status!=='active'))throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')
     return entry
   }
   private assert(lease:ComputerLease){
-    const entry=this.require(lease),active=this.active.get(lease.id)
-    if(this.closing||!active||active.controller.signal.aborted||entry.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑控制授权已到期')
+    const entry=this.require(lease),active=entry.holders.get(lease.id)!
+    if(this.closing||entry.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑控制授权已到期')
     active.authorize()
   }
   async acquire(spec:ComputerSpecification,holderId:string,authorize:()=>void,signal?:AbortSignal):Promise<ComputerLease>{
@@ -61,22 +72,33 @@ export class ComputerPool {
       if(!this.ready||this.closing)throw new ComputerError('computer_pool_unavailable','电脑资源正在恢复或关闭')
       let entry=this.get(spec.id)
       if(entry&&(entry.spec.ownerKey!==spec.ownerKey||JSON.stringify(entry.spec)!==JSON.stringify(spec)))throw new ComputerError('computer_owner_mismatch','电脑环境归属或配置不匹配')
-      if(entry?.status==='active'&&entry.lease?.holderId===holderId&&this.active.has(entry.lease.id)){this.assert(entry.lease);return entry.lease}
-      if(entry&&entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
+      const own=entry&&entry.status==='active'?[...entry.holders.values()].find(active=>active.lease.holderId===holderId):undefined
+      if(own){this.assert(own.lease);return own.lease}
+      // An active environment is shared: a new holder joins it without
+      // touching the container or the concurrency quota.
+      const joining=entry?.status==='active'
+      if(!joining&&entry&&entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑正在使用或需要核对停止状态')
       const rows=this.rows()
-      if((!entry&&rows.length>=this.limits.environments)||rows.filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
-      const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:(entry?.generation??0)+1}
-      entry={spec:structuredClone(spec),lease,generation:lease.generation,status:'preparing',expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()}
+      if((!entry&&rows.length>=this.limits.environments)||(!joining&&rows.filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent))throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
+      const lease:ComputerLease={id:randomUUID(),environmentId:spec.id,ownerKey:spec.ownerKey,holderId,generation:joining?(entry as Entry).generation:(entry?.generation??0)+1}
+      if(!joining){entry={spec:structuredClone(spec),generation:lease.generation,status:'preparing',holders:new Map(),expiresAt:this.now()+this.limits.ttlMs,updatedAt:this.now()};this.entries.set(spec.id,entry)}
+      const holder=entry as Entry
       // This reservation is synchronous, before any provider await or next acquire.
-      this.save(entry)
-      const abort=()=>{const current=this.get(spec.id);if(current?.lease?.id===lease.id)this.invalidate(current)}
-      const active:Active={lease,authorize,controller:new AbortController(),operations:new Set(),detach:()=>signal?.removeEventListener('abort',abort)};this.active.set(lease.id,active)
+      const abort=()=>{const current=this.get(spec.id);if(current?.holders.has(lease.id)){this.removeHolder(current,lease.id);this.save(current);if(!current.holders.size){current.generation++;current.status='stopping';current.expiresAt=0;this.save(current)}void this.serial(spec.id,()=>this.stopEntry(current!)).catch(()=>{})}}
+      const active:Active={lease,authorize,controller:new AbortController(),operations:new Set(),detach:()=>signal?.removeEventListener('abort',abort)}
+      holder.holders.set(lease.id,active)
+      holder.expiresAt=Math.max(holder.expiresAt,this.now()+this.limits.ttlMs)
+      this.save(holder)
       signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort()
+      if(joining){authorize();return lease}
       const guard=()=>{const current=this.require(lease,false);if(this.closing||active.controller.signal.aborted||current.status!=='preparing'||current.expiresAt<=this.now())throw new ComputerError('computer_lease_expired','电脑启动授权已失效');authorize()}
       try{
         await this.provider.ensure(spec,guard);guard()
-        entry.status='active';this.save(entry);return lease
-      }catch(error){await this.stopEntry(entry).catch(()=>{});throw error}
+        holder.status='active';this.save(holder);return lease
+      }catch(error){
+        this.removeHolder(holder,lease.id)
+        await this.stopEntry(holder).catch(()=>{});throw error
+      }
     })
   }
   async configure(spec:ComputerSpecification,authorize:()=>void):Promise<void>{
@@ -93,43 +115,44 @@ export class ComputerPool {
       catch(error){entry.status='uncertain';this.save(entry);throw error}
     })
   }
-  async transfer(lease:ComputerLease,holderId:string,authorize:()=>void):Promise<ComputerLease>{
-    return this.serial(lease.environmentId,async()=>{
-      this.assert(lease);authorize()
-      const active=this.active.get(lease.id)!
-      if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有在途操作,不能移交控制')
-      const entry=this.require(lease),state=await this.provider.inspect(entry.spec)
-      this.assert(lease);authorize()
-      if(active.operations.size||!state?.running)throw new ComputerError('computer_busy','电脑尚未就绪,不能移交控制')
-      const next:ComputerLease={...lease,id:randomUUID(),holderId,generation:entry.generation+1}
-      active.detach();active.controller.abort();this.active.delete(lease.id)
-      this.active.set(next.id,{lease:next,authorize,controller:new AbortController(),operations:new Set(),detach:()=>{}})
-      entry.lease=next;entry.generation=next.generation;entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry)
-      return next
-    })
-  }
   authorize(lease:ComputerLease){this.assert(lease)}
-  renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry);void this.provider.renewFence?.(entry.spec).catch(()=>{try{this.invalidate(this.require(lease))}catch{}})}
-  use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void})=>Promise<T>):Promise<T>{
+  renew(lease:ComputerLease):void{this.assert(lease);const entry=this.require(lease);entry.expiresAt=this.now()+this.limits.ttlMs;this.save(entry);void this.provider.renewFence?.(entry.spec).catch(()=>{})}
+  use<T>(lease:ComputerLease,action:(context:{signal:AbortSignal;authorize():void;mayFence():boolean})=>Promise<T>):Promise<T>{
     this.assert(lease)
-    const active=this.active.get(lease.id)!
-    const pending=Promise.resolve().then(()=>{this.assert(lease);return action({signal:active.controller.signal,authorize:()=>this.assert(lease)})}).then(value=>{this.assert(lease);return value})
+    const active=this.require(lease).holders.get(lease.id)!
+    const pending=Promise.resolve().then(()=>{this.assert(lease);return action({signal:active.controller.signal,authorize:()=>this.assert(lease),mayFence:()=>{try{const entry=this.require(lease,false);return entry.status==='active'&&entry.holders.size<=1}catch{return false}}})}).then(value=>{this.assert(lease);return value})
     active.operations.add(pending);void pending.finally(()=>active.operations.delete(pending)).catch(()=>{})
     return pending
   }
+  /** Synchronous removal of one holder; true when it was the last one. */
+  private removeHolder(entry:Entry,leaseId:string):boolean{
+    const active=entry.holders.get(leaseId)
+    if(active){entry.holders.delete(leaseId);active.controller.abort();active.detach()}
+    return entry.holders.size===0
+  }
   release(lease:ComputerLease,keepRunning=false):Promise<void>{
-    if(keepRunning)return this.serial(lease.environmentId,async()=>{
-      this.assert(lease)
-      const entry=this.require(lease),active=this.active.get(lease.id)!
-      if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能进入空闲状态')
-      if(this.provider.releaseFence)await this.provider.releaseFence(entry.spec,true)
-      active.detach();active.controller.abort();this.active.delete(lease.id)
-      entry.status='idle';entry.generation++;entry.lease=undefined;entry.expiresAt=0;this.save(entry)
+    // Synchronous identity check, like the original fence: an already-ended
+    // lease must throw immediately rather than reject after queuing.
+    {const entry=this.get(lease.environmentId),active=entry?.holders.get(lease.id)
+    if(!entry||!active||entry.spec.ownerKey!==lease.ownerKey||active.lease.holderId!==lease.holderId)throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')}
+    return this.serial(lease.environmentId,async()=>{
+      const entry=this.get(lease.environmentId),active=entry?.holders.get(lease.id)
+      if(!entry||!active||entry.spec.ownerKey!==lease.ownerKey||active.lease.holderId!==lease.holderId)throw new ComputerError('computer_lease_stale','电脑控制权已改变,旧操作不能继续')
+      // A fencing release stops without further authorization, mirroring the
+      // stop path: it must proceed even after the holder's grant just lapsed.
+      if(keepRunning){
+        this.assert(lease)
+        if(active.operations.size)throw new ComputerError('computer_busy','电脑仍有未结束操作,暂不能交出环境')
+      }
+      this.removeHolder(entry,lease.id)
+      if(entry.holders.size){this.save(entry);return}
+      if(keepRunning){
+        if(this.provider.releaseFence)await this.provider.releaseFence(entry.spec,true)
+        entry.status='idle';entry.generation++;entry.expiresAt=0;this.save(entry);return
+      }
+      this.invalidate(entry)
+      await this.stopEntry(entry)
     })
-    const existing=this.get(lease.environmentId)
-    const entry=existing&&existing.lease?.id===lease.id&&existing.spec.ownerKey===lease.ownerKey&&existing.lease.holderId===lease.holderId&&['stopping','uncertain'].includes(existing.status)?existing:this.require(lease,false)
-    this.invalidate(entry)
-    return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
   }
   definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
   async desktop(spec:ComputerSpecification,action:'create'|'start'|'stop'|'recreate'|'remove',authorize:()=>void){
@@ -143,13 +166,13 @@ export class ComputerPool {
       const starts=['create','start','recreate'].includes(action)
       if(starts&&this.rows().filter(row=>row.status!=='free'&&row.spec.id!==spec.id).length>=this.limits.concurrent)throw new ComputerError('computer_quota','本地虚拟机数量已达上限,请先停止另一台桌面')
       if(!entry&&!starts)return
-      entry??={spec,generation:0,status:'free',expiresAt:0,updatedAt:this.now()}
+      entry??={spec,generation:0,status:'free',holders:new Map(),expiresAt:0,updatedAt:this.now()};this.entries.set(spec.id,entry)
       entry.status='preparing';entry.generation++;this.save(entry)
       try {
         if(['stop','recreate','remove'].includes(action)) {await this.provider.stop(entry.spec);await this.provider.remove(entry.spec)}
         authorize()
         if(starts){entry.spec=spec;await this.provider.ensure(spec,authorize);entry.status='idle'}else entry.status='free'
-        if(action==='remove')this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(spec.id)
+        if(action==='remove'){this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(spec.id);this.entries.delete(spec.id)}
         else this.save(entry)
       }catch(error){entry.status='uncertain';this.save(entry);throw error}
     })
@@ -161,35 +184,39 @@ export class ComputerPool {
       if(entry.status!=='free'&&entry.status!=='idle')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
       if(entry.status==='idle')await this.provider.stop(entry.spec)
       await this.provider.remove(entry.spec)
-      this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
+      this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id);this.entries.delete(id)
       return entry.spec
     })
   }
   async stopHolder(ownerKey:string,id:string,holderId:string):Promise<void>{
-    const entry=this.get(id)
-    if(!entry||entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
-    if(entry.lease?.holderId!==holderId)return
-    this.invalidate(entry);await this.serial(id,()=>this.stopEntry(entry))
+    return this.serial(id,async()=>{
+      const entry=this.get(id)
+      if(!entry||entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
+      const active=[...entry.holders.values()].find(active=>active.lease.holderId===holderId)
+      if(!active)return
+      this.removeHolder(entry,active.lease.id)
+      if(entry.holders.size)this.save(entry)
+      else{this.invalidate(entry);await this.stopEntry(entry)}
+    })
   }
+  /** The stop fence: ends every holder, then confirms the container stopped. */
   private invalidate(expected:Entry){
     const entry=this.get(expected.spec.id)
-    if(!entry||entry.lease?.id!==expected.lease?.id||entry.status==='free')return
+    if(!entry||entry.status==='free')return undefined
     if(entry.status!=='stopping'){entry.generation++;entry.status='stopping';entry.expiresAt=0;this.save(entry)}
-    const active=entry.lease?this.active.get(entry.lease.id):undefined
-    active?.controller.abort()
+    for(const active of entry.holders.values()){active.controller.abort();active.detach()}
+    entry.holders.clear()
     return entry
   }
   private async stopEntry(expected:Entry):Promise<void>{
     const entry=this.invalidate(expected)
     if(!entry)return
-    const active=entry.lease?this.active.get(entry.lease.id):undefined
     try{
       await this.provider.stop(entry.spec)
       // Retire the runtime identity as well as the lease generation. A delayed
       // exec targeting the old container ID can never enter its successor.
       await this.provider.remove(entry.spec)
-      active?.detach();if(entry.lease)this.active.delete(entry.lease.id)
-      entry.status='free';entry.lease=undefined;entry.expiresAt=0;this.save(entry)
+      entry.status='free';entry.expiresAt=0;this.save(entry)
     }catch(error){entry.status='uncertain';this.save(entry);throw error}
   }
   async expire():Promise<void>{

+ 30 - 23
src/runner/worker/control.ts

@@ -1,10 +1,9 @@
 import {randomUUID,createHash} from 'node:crypto'
 import {z} from 'zod'
 import {HttpError} from '../../server/errors.js'
-import {CUA_DRIVER,CUA_SOCKET,type ComputerSpecification} from '../computers/container.js'
+import {CUA_DRIVER,CUA_SOCKET,COMPUTER_WORKSPACE,type ComputerSpecification} from '../computers/container.js'
 import type {ComputerLease} from '../computers/pool.js'
-import {ComputerPublicProxy} from '../network/computerProxy.js'
-import type {ComputerRuntime,ComputerGateway,ComputerTarget} from './gateway.js'
+import type {ComputerRuntime,ComputerGateway,ComputerTarget,ProxyHandle} from './gateway.js'
 import type {ComputerControlStatus,ComputerFrame} from '../../shared/computerControl.js'
 
 const actionSchema=z.discriminatedUnion('kind',[
@@ -16,7 +15,7 @@ const actionSchema=z.discriminatedUnion('kind',[
 ])
 interface Manual {
   id:string;meta:ComputerTarget;profile:string;mode:ComputerControlStatus['mode'];valid:boolean
-  gateway?:ComputerGateway;lease?:ComputerLease;spec?:ComputerSpecification;proxy?:ComputerPublicProxy
+  gateways:ComputerGateway[];lease?:ComputerLease;spec?:ComputerSpecification;proxyHandle?:ProxyHandle
   pending?:Promise<void>;error?:string;check():Promise<void>;checking:boolean
   stopping?:Promise<void>;actions:Map<string,{fingerprint:string;result:Promise<unknown>}>
 }
@@ -35,10 +34,13 @@ export class ComputerControls {
   private state(meta:ComputerTarget){const state=this.manual.get(meta.environmentId);if(state&&state.meta.ownerKey!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch');return state}
   async status(meta:ComputerTarget):Promise<ComputerControlStatus>{
     const state=this.state(meta)
-    if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateway,error:state.error}
+    if(state)return {mode:state.mode,controlId:state.id,generation:state.lease?.generation,canResume:!!state.gateways.length,error:state.error}
+    const gateways=[...(this.runtime.gateways.get(meta.environmentId)??[])]
+    if(gateways.some(gateway=>gateway.paused))return {mode:'pausing',generation:this.runtime.pool.status(meta.ownerKey).find(row=>row.environmentId===meta.environmentId)?.generation}
     const resource=this.runtime.pool.status(meta.ownerKey).find(item=>item.environmentId===meta.environmentId)
     if(this.runtime.provider.fixedCapacity&&(!resource||['free','idle'].includes(resource.status))){const state=await this.runtime.provider.inspect({id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.config.imageId});return {mode:state?.running?'idle':'off',generation:resource?.generation??0}}
-    return {mode:resource?.status==='active'?'agent':resource?.status==='idle'?'idle':'off',generation:resource?.generation}
+    const human=resource?.holderIds.some(holderId=>holderId.startsWith('human:'))
+    return {mode:gateways.some(gateway=>gateway.active)||resource?.status==='active'?(human?'resuming':'agent'):resource?.status==='idle'?'idle':'off',generation:resource?.generation}
   }
   async frame(meta:ComputerTarget,authorize:()=>void):Promise<ComputerFrame>{
     this.runtime.assertTarget(meta);authorize()
@@ -58,18 +60,23 @@ export class ComputerControls {
     this.runtime.assertTarget(meta);await check()
     const old=this.state(meta)
     if(old){if(old.id===id)return this.status(meta);throw new HttpError(409,'电脑已由另一控制会话接管','computer_control_busy')}
-    const gateway=this.runtime.gateways.get(meta.environmentId)
-    const state:Manual={id,meta,profile,mode:'pausing',valid:true,gateway:gateway?.active?gateway:undefined,check,checking:false,actions:new Map()}
+    // Pause every bot currently working on this desktop; the human joins the
+    // same shared environment as an additional holder of its own.
+    const gateways=this.runtime.activeGateways(meta.environmentId)
+    const state:Manual={id,meta,profile,mode:'pausing',valid:true,gateways,check,checking:false,actions:new Map()}
     this.manual.set(meta.environmentId,state)
     state.pending=(async()=>{
-      if(state.gateway){state.lease=await state.gateway.takeControl(id,()=>this.guard(state));state.spec=state.gateway.specification}
-      else{
+      for(const gateway of gateways)await gateway.takeControl(id,()=>this.guard(state))
+      const resource=this.runtime.pool.status(meta.ownerKey).find(row=>row.environmentId===meta.environmentId)
+      let spec=this.runtime.pool.definition(meta.ownerKey,meta.environmentId)
+      if(!spec||resource?.status!=='active'){
         const resolved=await this.runtime.resolveWorkspace(profile);await check();this.guard(state)
-        state.spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.runtime.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??'/home/cua/workspace'):resolved.cwd,network:this.runtime.config.network??'none'}
-        await this.runtime.pool.configure(state.spec,()=>this.guard(state))
-        state.lease=await this.runtime.pool.acquire(state.spec,`human:${id}`,()=>this.guard(state))
-        if(this.runtime.config.network==='public-proxy')state.proxy=await ComputerPublicProxy.start(this.runtime.provider,state.spec,this.runtime.proxyScript,()=>this.runtime.pool.authorize(state.lease!),check,()=>{void this.stop(state)})
+        spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:this.runtime.imageFor(meta),cwd:meta.environmentId!==meta.agentId?(this.runtime.pool.definition(meta.ownerKey,meta.environmentId)?.cwd??COMPUTER_WORKSPACE):resolved.cwd,network:this.runtime.config.network??'none'}
+        await this.runtime.pool.configure(spec,()=>this.guard(state))
       }
+      state.spec=spec
+      state.lease=await this.runtime.pool.acquire(spec,`human:${id}`,()=>this.guard(state))
+      if(this.runtime.config.network==='public-proxy')state.proxyHandle=await this.runtime.acquireProxy(spec,()=>{if(!this.runtime.pool.hasHolders(meta.ownerKey,meta.environmentId))throw new HttpError(410,'电脑控制权已结束','computer_control_expired')})
       await check();this.guard(state);state.mode='human'
     })().catch(async()=>{if(!state.valid)return;state.mode='error';state.error='接管未完成,请等待当前操作结束后重试';await this.stop(state).catch(()=>{})})
     return this.status(meta)
@@ -101,7 +108,7 @@ export class ComputerControls {
     }else {tool='scroll';args={...common,x:Math.floor(frame.width/2),y:Math.floor(frame.height/2),by:'line',direction:action.direction,amount:action.amount}}
     const result=this.runtime.pool.use(state.lease,async context=>{
       this.guard(state);await state.check()
-      const response=await this.runtime.provider.execute(state.spec!,argv??[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],context)
+      const response=await this.runtime.provider.execute(state.spec!,argv??[CUA_DRIVER,'call',tool,JSON.stringify(args),'--socket',CUA_SOCKET],{...context,lane:'gui'})
       this.guard(state);let parsed:any;try{parsed=JSON.parse(response.stdout)}catch{}
       if(parsed?.code||parsed?.error||parsed?.isError)throw new HttpError(502,'电脑未接受这次输入,请刷新画面后重试','computer_action_failed')
       return {ok:true,result:response.stdout}
@@ -111,20 +118,20 @@ export class ComputerControls {
   async giveBack(meta:ComputerTarget,id:string,notes:string){
     const state=this.state(meta);if(!state||state.id!==id)throw new HttpError(410,'电脑控制权已结束','computer_control_expired')
     await state.pending;this.guard(state);await state.check()
-    if(state.gateway){state.mode='resuming';await state.gateway.giveBack(notes);state.valid=false;this.manual.delete(meta.environmentId)}
-    else if(this.runtime.retainDesktops&&state.lease){
-      await state.proxy?.close();await this.runtime.pool.release(state.lease,true)
-      state.valid=false;this.manual.delete(meta.environmentId)
-    }else await this.stop(state)
+    state.mode='resuming'
+    await state.proxyHandle?.release()
+    if(state.lease)await this.runtime.pool.release(state.lease,this.runtime.retainDesktops).catch(error=>{if((error as any).code!=='computer_lease_stale')throw error})
+    for(const gateway of state.gateways)await gateway.giveBack(notes)
+    state.valid=false;this.manual.delete(meta.environmentId)
     return {ok:true}
   }
   private async stop(state:Manual){
     if(state.stopping)return state.stopping
     state.valid=false
     state.stopping=(async()=>{
-      await state.proxy?.close()
-      if(state.gateway)await state.gateway.stopControl()
-      else if(state.lease)await this.runtime.pool.release(state.lease).catch(error=>{if(error.code!=='computer_lease_stale')throw error})
+      await state.proxyHandle?.release()
+      for(const gateway of state.gateways)await gateway.stopControl()
+      if(state.lease)await this.runtime.pool.release(state.lease).catch(error=>{if(error.code!=='computer_lease_stale')throw error})
       if(this.manual.get(state.meta.environmentId)===state)this.manual.delete(state.meta.environmentId)
     })().catch(error=>{state.mode='error';state.error='电脑停止状态待确认';state.stopping=undefined;throw error})
     return state.stopping

+ 45 - 19
src/runner/worker/gateway.ts

@@ -23,7 +23,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;pauseJob?:Promise<ComputerLease>;segment?:number;toolCalls:Set<Promise<unknown>>;journal:Map<string,{name:string;result:unknown}>;proxy?:ComputerPublicProxy;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;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}
 const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
 const text={type:'string'}
 const TOOLS:WorkerTool[]=[
@@ -38,6 +38,7 @@ const pathField=z.string().min(1).max(4096).refine(value=>!value.includes('\0'))
 const PROFILE_COMPUTER_RULES='当前使用本机协作模式:沿用基础 Profile 的本机工具、配置和上下文,本地虚拟机是独立的 Linux 电脑。computer_shell/read_file/write_file/desktop_state/action/export 只操作虚拟机;本机文件、终端和程序使用 Profile 已授权的原生工具。computer_copy_file 在两个环境间显式复制文件。调用前明确目标环境,文件路径和浏览器登录互不通用;需要回传虚拟机文件时使用 computer_export。人工接管期间停止操作,交还后先检查当前状态。'
 const HERMES_COMPUTER_RULES='本轮由 Hermes 管理模型、配置、识图、技能和授权资源。使用 Hermes 原生 skills_list、skill_view 和 skill_manage 发现、读取和维护技能;遵循当前 Profile 的技能规则。技能依赖本机登录态、环境变量或授权文件时,使用 Hermes 原生工具完成相应步骤;适合 Linux 的脚本与输入文件可经 computer_copy_file 传入虚拟机,再用 computer_shell 执行。路径、软件和登录态在本机与虚拟机之间不通用。不要自行复制 Profile 配置或凭据到虚拟机。'
 const ISOLATED_COMPUTER_RULES='当前使用隔离虚拟机:命令、文件处理和浏览器操作默认通过 computer_* 工具在指定的 Linux 电脑执行。需要使用本机文件或运行本机技能时,遵循 Hermes 返回的授权请求;拒绝后停止该操作。人工接管期间停止操作,交还后先检查当前状态。'
+export interface ProxyHandle {readonly proxy:ComputerPublicProxy;release():Promise<void>}
 export class ComputerRuntime {
   retainDesktops=false
   readonly controls:ComputerControls
@@ -45,10 +46,11 @@ export class ComputerRuntime {
   readonly pool:ComputerPool
   readonly ready:Promise<void>
   readonly config:NonNullable<RunnerConfiguration['computers']>
-  readonly gateways=new Map<string,ComputerGateway>()
+  readonly gateways=new Map<string,Set<ComputerGateway>>()
   readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
   readonly script:string
   readonly proxyScript:string
+  private proxyShares=new Map<string,{started:Promise<ComputerPublicProxy>;users:number}>()
   constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string,relay?:DesktopRelay,readonly hermesTarget?:GatewayTarget){
     if(!config.computers)throw new HttpError(409,'执行节点尚未配置隔离电脑','computer_unavailable')
     this.config=config.computers
@@ -66,6 +68,30 @@ export class ComputerRuntime {
     this.controls=new ComputerControls(this)
     this.ready=this.pool.recover();void this.ready.catch(()=>{})
   }
+  attachGateway(gateway:ComputerGateway){let gateways=this.gateways.get(gateway.meta.environmentId);if(!gateways){gateways=new Set();this.gateways.set(gateway.meta.environmentId,gateways)}gateways.add(gateway)}
+  detachGateway(gateway:ComputerGateway){const gateways=this.gateways.get(gateway.meta.environmentId);if(gateways){gateways.delete(gateway);if(!gateways.size)this.gateways.delete(gateway.meta.environmentId)}}
+  activeGateways(environmentId:string):ComputerGateway[]{return [...(this.gateways.get(environmentId)??[])].filter(gateway=>gateway.active||gateway.paused)}
+  /** One guest proxy per environment, shared by every holder; the guest
+   * reserves port 3128, so concurrent holders must never start their own. */
+  acquireProxy(spec:ComputerSpecification,authorize:()=>void):Promise<ProxyHandle>{
+    let share=this.proxyShares.get(spec.id)
+    if(!share){
+      const started=ComputerPublicProxy.start(this.provider,spec,this.proxyScript,authorize,async()=>authorize(),()=>{if(this.proxyShares.get(spec.id)===share)this.proxyShares.delete(spec.id)})
+      share={started,users:0};this.proxyShares.set(spec.id,share)
+      void started.catch(()=>{if(this.proxyShares.get(spec.id)===share)this.proxyShares.delete(spec.id)})
+    }
+    return share.started.then(proxy=>{
+      share!.users++
+      let released=false
+      return {proxy,release:async()=>{
+        if(released)return;released=true
+        const current=this.proxyShares.get(spec.id)
+        if(current===share&&--share!.users>0)return
+        if(current===share)this.proxyShares.delete(spec.id)
+        await proxy.close().catch(()=>{})
+      }}
+    })
+  }
   assertTarget(meta:ComputerTarget){if(this.db.prepare('SELECT id FROM retired_computers WHERE id=?').get(meta.environmentId))throw new HttpError(410,'临时电脑已退役','computer_retired')}
   imageFor(meta:ComputerTarget):string {
     const spec=this.pool.definition(meta.ownerKey,meta.environmentId)
@@ -80,7 +106,7 @@ export class ComputerRuntime {
     this.db.prepare('INSERT OR IGNORE INTO retired_computers VALUES(?,?,NULL)').run(meta.environmentId,meta.ownerKey)
     await Promise.all([...this.workers.values()].filter(worker=>worker.environmentId===meta.environmentId&&worker.ownerKey===meta.ownerKey).map(worker=>worker.process.close()))
     const current=this.pool.status(meta.ownerKey).find(row=>row.environmentId===meta.environmentId)
-    if(current?.holderId)await this.pool.stopHolder(meta.ownerKey,meta.environmentId,current.holderId)
+    for(const holderId of current?.holderIds??[])await this.pool.stopHolder(meta.ownerKey,meta.environmentId,holderId)
     let spec=old?.spec?JSON.parse(old.spec):undefined
     spec??=this.pool.definition(meta.ownerKey,meta.environmentId)
     if(spec)this.db.prepare('UPDATE retired_computers SET spec=? WHERE id=?').run(JSON.stringify(spec),meta.environmentId)
@@ -165,7 +191,7 @@ export class ComputerGateway {
   private profileSession?:ProfileComputerSession
   private skillSession?:ProfileSkillSession
   private get hermesManaged(){return this.meta.hermesRuntime===true}
-  private get rules(){return this.hermesManaged?`${this.meta.profileSession?PROFILE_COMPUTER_RULES:ISOLATED_COMPUTER_RULES}\n${HERMES_COMPUTER_RULES}`:`${PROFILE_COMPUTER_RULES}\n${SKILL_RULES}`}
+  private get rules(){const sharing=this.meta.environmentId!==this.meta.agentId?'多个机器人共用这台虚拟机:命令和文件操作并行执行,桌面操作交替进行;动手前注意其他机器人可能正在使用同一桌面,避免覆盖彼此的工作。':'';return this.hermesManaged?`${sharing}${this.meta.profileSession?PROFILE_COMPUTER_RULES:ISOLATED_COMPUTER_RULES}\n${HERMES_COMPUTER_RULES}`:`${sharing}${PROFILE_COMPUTER_RULES}\n${SKILL_RULES}`}
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>;workspaceMemory:boolean}
   constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>,readonly profileTarget?:GatewayTarget){ }
   async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized();if(this.hermesManaged&&!this.profileTarget)throw new HttpError(409,'缺少 Hermes 会话连接,不能回退到独立 Worker。','computer_profile_unavailable')}
@@ -180,7 +206,7 @@ export class ComputerGateway {
     if(method==='session.create'||method==='session.resume'){
       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?.holderId===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(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)}
@@ -194,10 +220,10 @@ export class ComputerGateway {
       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()}
-      this.runtime.save(session);this.runtime.gateways.set(this.meta.environmentId,this)
+      this.runtime.save(session);this.runtime.attachGateway(this)
       if(this.runtime.config.network==='public-proxy'){
         const live=this.live
-        try{live.proxy=await ComputerPublicProxy.start(this.runtime.provider,this.spec(session),this.runtime.proxyScript,()=>this.runtime.pool.authorize(live.lease),()=>this.authorized(),()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})})}
+        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()
@@ -332,7 +358,7 @@ export class ComputerGateway {
         return await this.skillSession.call(name,args)
       }
       return await this.runtime.pool.use(live.lease,async context=>{
-        const execute=(argv:string[],input?:Buffer)=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})
+        const execute=(argv:string[],input?:Buffer,lane?:'exec'|'gui')=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input,...(lane?{lane}:{})})
         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'){
           const body=z.object({from:z.enum(['host','vm']),source:pathField,destination:pathField}).strict().parse(args),host={...context,cwd:this.hostCwd}
@@ -385,12 +411,12 @@ export class ComputerGateway {
         }
         if(name==='computer_action'){
           const body=z.object({name:z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]{0,99}$/),arguments:z.record(z.string(),z.unknown())}).strict().parse(args)
-          return execute([CUA_DRIVER,'call',body.name,JSON.stringify(body.arguments),'--socket',CUA_SOCKET])
+          return execute([CUA_DRIVER,'call',body.name,JSON.stringify(body.arguments),'--socket',CUA_SOCKET],undefined,'gui')
         }
         if(name==='computer_desktop_state'){
           z.object({}).strict().parse(args)
           const path=`/tmp/yaoyao-frame-${randomUUID()}.png`
-          const state=await execute([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',path])
+          const state=await execute([CUA_DRIVER,'call','get_desktop_state','{}','--socket',CUA_SOCKET,'--screenshot-out-file',path],undefined,'gui')
           const data=(await execute(['base64','-w0',path])).stdout.trim()
           return {_multimodal:true,text_summary:state.stdout,content:[{type:'image_url',image_url:{url:`data:image/png;base64,${data}`}}]}
         }
@@ -425,7 +451,7 @@ export class ComputerGateway {
   private release(live:Live,keepRunning=false){
     clearInterval(this.timer)
     live.releasing??=(async()=>{
-      try{await live.proxy?.close();await this.runtime.pool.release(live.lease,keepRunning)}
+      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}
     })()
     return live.releasing
@@ -438,11 +464,14 @@ export class ComputerGateway {
   get paused(){return this.live?.paused===true}
   get lease(){return this.live?.lease}
   get specification(){return this.live?this.spec(this.live.session):undefined}
-  async takeControl(controlId:string,authorize:()=>void):Promise<ComputerLease>{
+  /** Pauses this turn for a human takeover: settles in-flight operations and
+   * records pending tool calls so the model can resume coherently. The pool
+   * lease stays — the human acquires a holder of the same shared desktop. */
+  async takeControl(controlId:string,authorize:()=>void):Promise<void>{
     const live=this.live
     if(!live||!this.active)throw new HttpError(409,'机器人已不在执行,请重新打开电脑','computer_not_active')
-    if(live.pauseJob)return live.pauseJob
-    live.paused=true;this.event('computer.paused',{stage:'pausing'})
+    if(live.pauseJob)await live.pauseJob
+    if(!live.paused){live.paused=true;this.event('computer.paused',{stage:'pausing'})}
     live.pauseJob=(async()=>{
       await this.profileSession?.stop()
       await live.worker?.close()
@@ -455,17 +484,14 @@ 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)
-      live.lease=await this.runtime.pool.transfer(live.lease,`human:${controlId}`,authorize)
       this.event('computer.paused',{stage:'human',generation:live.lease.generation})
-      return live.lease
     })()
-    return live.pauseJob
+    await live.pauseJob
   }
   async giveBack(notes:string):Promise<void>{
     const live=this.live
     if(!live?.paused)throw new HttpError(409,'机器人没有处于接管状态','computer_not_paused')
     await live.pauseJob;await this.authorized()
-    live.lease=await this.runtime.pool.transfer(live.lease,this.workId,()=>this.guard())
     live.paused=false;live.pauseJob=undefined;live.journal.clear()
     this.event('computer.resumed',{generation:live.lease.generation,notes})
     if(this.profileSession){
@@ -490,5 +516,5 @@ export class ComputerGateway {
     return live.stopping
   }
   async stopControl(){await this.interrupt();this.event('message.complete',{text:'电脑控制已结束,本轮已停止',status:'interrupted'})}
-  async close(){if(this.runtime.gateways.get(this.meta.environmentId)===this)this.runtime.gateways.delete(this.meta.environmentId);this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);try{if(this.live?.running||this.live&&!this.live.releasing)await this.interrupt()}finally{await this.profileSession?.close()}}
+  async close(){this.runtime.detachGateway(this);this.closed=true;this.controller.abort();this.team=undefined;clearInterval(this.timer);try{if(this.live?.running||this.live&&!this.live.releasing)await this.interrupt()}finally{await this.profileSession?.close()}}
 }

+ 25 - 10
tests/server/computerPool.test.ts

@@ -29,15 +29,26 @@ function fixture(limits={concurrent:2,environments:32,ttlMs:30000}){
   const pool=new ComputerPool(db,provider,limits,()=>clock)
   return {db,provider,pool,advance:(value:number)=>{clock+=value}}
 }
-it('reserves quota before starting, rejects another holder and rejects malformed specs before storage',async()=>{
+it('reserves quota before starting, shares an active environment and rejects malformed specs before storage',async()=>{
   const f=fixture({concurrent:1,environments:2,ttlMs:30000});await f.pool.recover()
   const first=spec();const lease=await f.pool.acquire(first,'task-one',()=>{})
   expect(await f.pool.acquire(first,'task-one',()=>{})).toEqual(lease)
-  await expect(f.pool.acquire(first,'task-two',()=>{})).rejects.toMatchObject({code:'computer_busy'})
+  // A second holder joins the active shared environment without a new container or quota.
+  const joined=await f.pool.acquire(first,'task-two',()=>{})
+  expect(joined.generation).toBe(lease.generation)
+  expect(f.provider.ensure).toHaveBeenCalledTimes(1)
+  expect(f.pool.status('owner')[0]?.holderIds).toEqual(expect.arrayContaining(['task-one','task-two']))
   await expect(f.pool.acquire(spec(),'task-three',()=>{})).rejects.toMatchObject({code:'computer_quota'})
   await expect(f.pool.acquire({...spec(),imageId:'untrusted:latest'},'invalid',()=>{})).rejects.toMatchObject({code:'computer_spec_invalid'})
   expect(f.db.prepare('SELECT count(*) AS n FROM computer_environments').get()?.n).toBe(1)
   expect(f.pool.status('different-owner')).toEqual([])
+  // One holder leaving keeps the desktop for the other; the last one stops it.
+  await f.pool.release(lease)
+  expect(f.pool.status('owner')[0]?.status).toBe('active')
+  expect(f.provider.stop).not.toHaveBeenCalled()
+  await f.pool.release(joined)
+  expect(f.pool.status('owner')[0]?.status).toBe('free')
+  expect(f.provider.stop).toHaveBeenCalledTimes(1)
   await f.pool.close()
 })
 it('fences cancellation before an old operation returns and waits for its stop before admitting a successor',async()=>{
@@ -93,7 +104,7 @@ it('does not expire a new holder acquired while an earlier environment stop was
   const replacement=await f.pool.acquire(b,'replacement',()=>{})
   release();await sweep
   expect(f.pool.status('owner').find(row=>row.environmentId===a.id)?.status).toBe('free')
-  expect(f.pool.status('owner').find(row=>row.environmentId===b.id)).toMatchObject({status:'active',holderId:'replacement',generation:replacement.generation})
+  expect(f.pool.status('owner').find(row=>row.environmentId===b.id)).toMatchObject({status:'active',holderIds:['replacement'],generation:replacement.generation})
   expect(()=>f.pool.renew(first)).toThrow()
   f.pool.renew(replacement);await f.pool.close()
 })
@@ -125,15 +136,19 @@ it('applies a changed authoritative cwd only after an environment is released',a
   expect(next.generation).toBeGreaterThan(lease.generation)
   await f.pool.close()
 })
-it('transfers an idle live computer with a new generation and rejects its previous holder',async()=>{
+it('lets a human takeover join an active environment and fence only its own holder',async()=>{
   const f=fixture();await f.pool.recover();const resource=spec(),lease=await f.pool.acquire(resource,'agent',()=>{})
   f.provider.inspect=vi.fn(async()=>({id:resource.id,containerId:'same-computer',running:true,workspace:'/fixture',isolation:'container'}))
-  const human=await f.pool.transfer(lease,'human',()=>{})
-  expect(human.generation).toBeGreaterThan(lease.generation)
-  expect(()=>f.pool.authorize(lease)).toThrow()
+  // The human becomes a second holder of the same live desktop — nothing stops.
+  const human=await f.pool.acquire(resource,'human:control',()=>{})
+  expect(human.generation).toBe(lease.generation)
+  expect(f.pool.status('owner')[0]?.holderIds).toEqual(expect.arrayContaining(['agent','human:control']))
+  expect(f.provider.stop).not.toHaveBeenCalled()
+  f.pool.authorize(human);f.pool.authorize(lease)
+  // Ending the human's control hands the desktop back without stopping it.
+  await f.pool.release(human,true)
+  expect(f.pool.status('owner')[0]?.status).toBe('active')
+  expect(f.pool.status('owner')[0]?.holderIds).toEqual(['agent'])
   expect(f.provider.stop).not.toHaveBeenCalled()
-  f.pool.authorize(human)
-  const resumed=await f.pool.transfer(human,'agent',()=>{})
-  expect(resumed.generation).toBeGreaterThan(human.generation)
   await f.pool.close()
 })

+ 56 - 1
tests/server/computerProvider.test.ts

@@ -7,12 +7,13 @@ import {join} from 'node:path'
 import {ContainerComputerProvider,type ContainerCommand,type ComputerSpecification} from '../../src/runner/computers/container'
 const homes:string[]=[]
 afterEach(async()=>{await Promise.all(homes.splice(0).map(home=>rm(home,{recursive:true,force:true})))})
-async function fixture(runtime:'docker'|'podman'='docker'){
+async function fixture(runtime:'docker'|'podman'='docker',onCommand?:(args:string[])=>Promise<void>){
   const home=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-computer-unit-')));homes.push(home)
   const spec:ComputerSpecification={id:randomUUID(),ownerKey:randomUUID(),imageId:`sha256:${'a'.repeat(64)}`}
   let detail:any
   const calls:string[][]=[]
   const run:ContainerCommand=async(_runtime,args)=>{
+    if(onCommand)await onCommand(args)
     calls.push(args)
     if(args[0]==='context')return {stdout:JSON.stringify('unix:///tmp/fixture-engine.sock'),stderr:''}
     if(args[0]==='system')return {stdout:'[]',stderr:''}
@@ -111,3 +112,57 @@ it('recreates a migrated mount while retaining the stopped original container an
   await expect((await import('node:fs/promises')).readFile(join(state.workspace,'keep.txt'),'utf8')).resolves.toBe('keep workspace')
   expect(f.calls.filter(args=>args[0]==='rm')).toHaveLength(0)
 })
+it('runs command lanes concurrently, serializes desktop input and drains every lane before lifecycle operations',async()=>{
+  const order:string[]=[],held:Array<()=>void>=[]
+  let active=0,peak=0
+  const f=await fixture('docker',async args=>{
+    if(args[0]==='exec'){
+      const marker=String(args.at(-1))
+      active++;peak=Math.max(peak,active)
+      try{
+        if(marker.startsWith('hold'))await new Promise<void>(resolve=>held.push(resolve))
+        order.push(`exec:${marker}`)
+      }finally{active--}
+    }
+    if(args[0]==='stop'||args[0]==='rm')order.push(args[0]!)
+  })
+  await f.provider.ensure(f.spec,()=>{})
+  // File commands from different holders overlap on the exec lane.
+  const slowFile=f.provider.execute(f.spec,['/bin/sh','-c','hold-a'],{authorize:()=>{}})
+  await f.provider.execute(f.spec,['/bin/sh','-c','plain-b'],{authorize:()=>{}})
+  expect(order).toContain('exec:plain-b')
+  // Desktop input is serialized: the second GUI action waits for the first.
+  const slowGui=f.provider.execute(f.spec,['driver','hold-gui-1'],{authorize:()=>{},lane:'gui'})
+  let guiTwoDone=false
+  const guiTwo=f.provider.execute(f.spec,['driver','plain-gui-2'],{authorize:()=>{},lane:'gui'}).then(()=>{guiTwoDone=true})
+  await new Promise(resolve=>setTimeout(resolve,25))
+  expect(guiTwoDone).toBe(false)
+  // A lifecycle operation drains both lanes before touching the container.
+  let stopped=false
+  const stopping=f.provider.stop(f.spec).then(()=>{stopped=true})
+  await new Promise(resolve=>setTimeout(resolve,25))
+  expect(stopped).toBe(false)
+  for(const release of held.splice(0))release()
+  await Promise.all([slowFile,slowGui,guiTwo,stopping])
+  expect(guiTwoDone).toBe(true)
+  expect(peak).toBeGreaterThanOrEqual(2)
+  expect(order.filter(entry=>entry.startsWith('exec:')).indexOf('exec:plain-gui-2')).toBeGreaterThan(order.indexOf('exec:hold-gui-1'))
+  expect(order.indexOf('stop')).toBeGreaterThan(order.indexOf('exec:plain-gui-2'))
+})
+it('keeps a shared desktop alive when one holder cancels an unacknowledged operation',async()=>{
+  let stopped=0
+  const f=await fixture('docker',async args=>{
+    if(args[0]==='stop')stopped++
+    if(args[0]==='exec'&&String(args.at(-1)).startsWith('boom'))throw Object.assign(new Error('unacknowledged'),{killed:true})
+  })
+  await f.provider.ensure(f.spec,()=>{})
+  // Another holder still shares the desktop: the ambiguous operation must not close it.
+  await expect(f.provider.execute(f.spec,['/bin/sh','-c','boom-shared'],{authorize:()=>{},mayFence:()=>false})).rejects.toThrow('unacknowledged')
+  await new Promise(resolve=>setTimeout(resolve,25))
+  expect(stopped).toBe(0)
+  await f.provider.execute(f.spec,['/bin/sh','-c','after'],{authorize:()=>{}})
+  // A sole holder keeps the original hard fence.
+  await expect(f.provider.execute(f.spec,['/bin/sh','-c','boom-alone'],{authorize:()=>{}})).rejects.toThrow('unacknowledged')
+  await new Promise(resolve=>setTimeout(resolve,25))
+  expect(stopped).toBe(1)
+})