Răsfoiți Sursa

修复 Bot 删除重试与原执行停止确认生命周期

Samien 1 lună în urmă
părinte
comite
26b6bd9815

+ 10 - 2
src/runner/agent.ts

@@ -20,7 +20,7 @@ import {BrowserRuntimeError} from './browser/types.js'
 import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
 import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
 
-const commands=new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','session.cwd.set','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
+const commands=new Set(['session.create','session.resume','session.close','session.usage','session.interrupt','session.active_list','session.cwd.set','prompt.submit','session.steer','image.attach_bytes','file.attach','approval.respond','clarify.respond'])
 interface Connection {cleanupOnly?:boolean;gateway:WorkspaceGateway|ComputerGateway;sessions:Map<string,string>;running:Set<string>;events:Promise<void>}
 interface Lease {connectionId:string;profile:string;lease:WorkspaceToolLease;controller:AbortController;session:{runtimeId:string;storedId:string}}
 
@@ -254,7 +254,15 @@ export class RunnerAgent {
       const connection=this.connections.get(connectionId),method=String(p.method),params=p.params as Record<string,unknown>
       const computerCommand=method==='computer.invoke'||method==='computer.transfer'||method==='computer.complete'
       if(!connection||(!commands.has(method)&&!(computerCommand&&connection.gateway instanceof ComputerGateway))||!params||typeof params!=='object')throw new HttpError(403,'执行通道或命令无效','runner_command_forbidden')
-      if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
+      if(connection.cleanupOnly&&!['session.resume','session.interrupt','session.close','session.active_list'].includes(method))throw new HttpError(403,'清理通道不允许执行新工作','runner_cleanup_forbidden')
+      if(method==='session.active_list'){
+        const id=String(params.current_session_id??''),profile=connection.sessions.get(id)
+        if(!profile||params.profile!==profile||Object.keys(params).some(key=>!['profile','current_session_id'].includes(key)))throw new HttpError(403,'停止状态查询不属于这个通道','runner_session_forbidden')
+        if(connection.gateway instanceof ComputerGateway)throw new HttpError(409,'电脑通道使用独立停止确认','computer_method_unavailable')
+        const state=await connection.gateway.rpc(method,{profile,current_session_id:id})
+        if(!Array.isArray(state?.sessions))throw new HttpError(502,'Hermes 停止状态无效','session_stop_unconfirmed')
+        return {sessions:state.sessions.filter((item:{id:string})=>item.id===id).map((item:{id:string;status:string})=>({id:item.id,status:item.status}))}
+      }
       if(method==='session.create'||method==='session.resume'){if(!connection.cleanupOnly)this.requireProfile(params.profile)}
       else if(!connection.sessions.has(String(params.session_id)))throw new HttpError(403,'会话不属于这个通道','runner_session_forbidden')
       if(method==='prompt.submit')connection.running.add(String(params.session_id))

+ 30 - 16
src/runner/worker/gateway.ts

@@ -29,8 +29,8 @@ import {FileTransferFiles} from '../../shared/fileTransferEndpoint.mjs'
 import {copyBetweenEndpoints} from '../../server/fileTransfer.js'
 
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean;hermesRuntime?:boolean;fileTransferMaxBytes?:number}
-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 {toolsOnly?:boolean;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}
+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;stopPending?:boolean}
+interface Live {toolsOnly?:boolean;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>;leaseController?:AbortController;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[]=[
@@ -113,7 +113,11 @@ export class ComputerRuntime {
   }
   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)}
+  activeGateways(environmentId:string):ComputerGateway[]{
+    if(this.stopPending(environmentId))throw new HttpError(409,'原电脑执行尚未确认停止,不能接管或复用','computer_stopping')
+    return [...(this.gateways.get(environmentId)??[])].filter(gateway=>gateway.active||gateway.paused)
+  }
+  stopPending(environmentId:string):boolean{return (this.db.prepare('SELECT value FROM computer_sessions').all() as {value:string}[]).some(row=>{const session:WorkerSession=JSON.parse(row.value);return session.environmentId===environmentId&&session.stopPending===true})}
   /** 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>{
@@ -272,12 +276,13 @@ export class ComputerGateway {
   private spec(session:WorkerSession):ComputerSpecification{return {id:this.meta.environmentId,ownerKey:this.meta.ownerKey,imageId:this.runtime.imageFor(this.meta),cwd:session.cwd,network:this.runtime.network}}
   async rpc(method:string,params:Record<string,any>):Promise<any>{
     if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.live?.session.id){await this.interrupt();return {status:'interrupted'}}
-    if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);return {status:'interrupted'}}
+    if((method==='session.interrupt'||method==='session.close')&&params.session_id===this.recoverySession?.id){await this.profileSession?.stop();await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);this.recoverySession!.stopPending=false;this.runtime.save(this.recoverySession!);return {status:'interrupted'}}
     this.guard()
     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?.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(method==='session.resume'&&params.recoverOnly===true){const session=this.runtime.session(String(params.session_id),this.meta,String(params.profile));this.recoverySession=session;session.stopPending=true;this.runtime.save(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);session.stopPending=false;this.runtime.save(session);return {session_id:session.id,stored_session_id:session.id,running:false,info:{profile_name:session.profile}}}
+      if(this.runtime.stopPending(this.meta.environmentId))throw new HttpError(409,'原电脑执行尚未确认停止,不能复用','computer_stopping')
       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()
@@ -439,14 +444,17 @@ export class ComputerGateway {
       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)
+      // Tool cancellation must not discard the reservation before native Hermes
+      // termination is confirmed. Only this existing holder may renew for cleanup.
+      const leaseController=new AbortController();live.leaseController=leaseController
+      const lease=await this.runtime.pool.acquire(this.spec(live.session),this.workId,()=>{if(live.session.stopPending&&!live.releasing)return;this.guard()},leaseController.signal)
       if(this.closed){await this.runtime.pool.release(lease);throw new Error('电脑通道已关闭')}
       live.lease=lease
       if(this.runtime.network!=='none'){
         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()
+      this.timer=setInterval(()=>{if(this.checking||!this.live?.lease)return;this.checking=true;void Promise.resolve().then(async()=>{if(!this.live!.session.stopPending)await this.authorized();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
@@ -625,6 +633,7 @@ export class ComputerGateway {
    * 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>{
+    this.guard()
     const live=this.live
     if(!live||!this.active)throw new HttpError(409,'机器人已不在执行,请重新打开电脑','computer_not_active')
     if(live.pauseJob)await live.pauseJob
@@ -663,16 +672,21 @@ export class ComputerGateway {
     const live=this.live;if(!live)return
     if(live.stopping)return live.stopping
     live.stopping=(async()=>{
-      live.controller.abort();clearInterval(this.timer)
-      try{await this.profileSession?.stop()}
-      finally{
-        await this.profileSession?.close();await live.worker?.close()
-        try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
-        live.running=false
-      }
-    })()
+      live.session.stopPending=true;this.runtime.save(live.session)
+      live.controller.abort()
+      await this.profileSession?.stop()
+      await this.profileSession?.close();await live.worker?.close()
+      try{await this.release(live)}catch(error){if((error as any).code!=='computer_lease_stale')throw error}
+      live.running=false;live.session.stopPending=false;this.runtime.save(live.session)
+      if(this.closed)this.runtime.detachGateway(this)
+    })().finally(()=>{live.stopping=undefined})
     return live.stopping
   }
   async stopControl(){await this.interrupt();this.event('message.complete',{text:'电脑控制已结束,本轮已停止',status:'interrupted'})}
-  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()}}
+  async close(){
+    this.closed=true;this.controller.abort();this.team=undefined
+    if(this.live?.running||this.live&&!this.live.releasing)await this.interrupt()
+    if(this.recoverySession?.stopPending){await this.profileSession?.stop();await this.runtime.pool.stopHolder(this.meta.ownerKey,this.meta.environmentId,this.workId);this.recoverySession.stopPending=false;this.runtime.save(this.recoverySession)}
+    await this.profileSession?.close();clearInterval(this.timer);this.runtime.detachGateway(this)
+  }
 }

+ 2 - 10
src/runner/worker/profileSession.ts

@@ -1,4 +1,4 @@
-import {setTimeout as delay} from 'node:timers/promises'
+import {waitForHermesSessionIdle} from '../../server/sessionStop.js'
 import {HttpError} from '../../server/errors.js'
 import {WorkspaceGateway,type GatewayFrame,type GatewayTarget} from '../../server/workspaceGateway.js'
 import {createWorkspaceToolLease,requireTeamToolBridge,type WorkspaceToolLease} from '../../server/workspaceToolLease.js'
@@ -95,15 +95,7 @@ export class ProfileComputerSession {
   async stop(){
     if(!this.runtimeId||this.closed)return
     await this.rpc('session.interrupt')
-    // An interrupt acknowledgement precedes actual termination of native tools.
-    const deadline=Date.now()+20000
-    for(;;){
-      const state=await this.rpc('session.active_list')
-      const session=state.sessions?.find((item:{id:string})=>item.id===this.runtimeId)
-      if(session?.status==='idle')return
-      if(Date.now()>=deadline)throw new HttpError(409,'本机任务尚未停止,暂不能交出电脑控制权','computer_profile_stopping')
-      await delay(100,undefined,{signal:this.controller.signal})
-    }
+    await waitForHermesSessionIdle((method,params)=>this.rpc(method,params),this.runtimeId,this.profile,{signal:this.controller.signal})
   }
   async close(){
     if(this.closed)return

+ 32 - 14
src/server/openVikingService.ts

@@ -38,6 +38,8 @@ export class OpenVikingService {
   readonly #clients = new Map<string, OpenVikingClient>()
   readonly #adminClients = new Map<string, OpenVikingClient>()
   readonly #registrations = new Map<string, Promise<OpenVikingUserBinding>>()
+  readonly #removals = new Map<string, Promise<void>>()
+  onConfigurationChanged: () => void = () => {}
   #configuration: OpenVikingConfiguration | undefined
   readonly #clientFactory: OpenVikingClientFactory
   constructor(private readonly store: WorkspaceStore, private readonly configurationManager: OpenVikingConfigurationManager, clientFactory?: OpenVikingClientFactory) {
@@ -51,11 +53,13 @@ export class OpenVikingService {
   }
   configure(config: OpenVikingConfiguration | undefined): void {
     const signature = config ? `${config.url}\0${config.accountId}\0${config.adminKey}` : ''
-    if (signature !== this.#signature) {
+    const changed=signature!==this.#signature
+    if (changed) {
       this.#clients.clear()
       this.#adminClients.clear()
     }
     this.#configuration = config
+    if(changed)this.onConfigurationChanged()
   }
   get #signature(): string { return this.#configuration ? `${this.#configuration.url}\0${this.#configuration.accountId}\0${this.#configuration.adminKey}` : '' }
   binding(owner: string, agentId: string): OpenVikingUserBinding | undefined {
@@ -92,6 +96,7 @@ export class OpenVikingService {
     return client
   }
   userClient(owner: string, agentId: string): OpenVikingClient {
+    if(this.store.get(owner,'agent-deletion',agentId))throw new HttpError(410,'Bot 已删除,记忆用户不可使用','openviking_user_removed')
     const config = this.#configuration
     if (!config) throw new HttpError(409, 'OpenViking 记忆服务未启用', 'openviking_disabled')
     const binding = this.binding(owner, agentId)
@@ -107,6 +112,8 @@ export class OpenVikingService {
   }
   async ensureUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<OpenVikingUserBinding> {
     const key = `${this.namespace}:${owner}:${agent.id}`
+    if(this.#removals.has(key)||this.store.get(owner,'agent-deletion',agent.id)||this.binding(owner,agent.id)?.status==='removed')
+      throw new HttpError(410,'Bot 已删除或正在移除记忆用户,不能重新注册','openviking_user_removed')
     const existing = this.#registrations.get(key)
     if (existing) return existing
     const pending = this.registerUser(owner, agent)
@@ -144,19 +151,30 @@ export class OpenVikingService {
     }
   }
   async removeUser(owner: string, agent: Pick<WorkspaceAgent, 'id' | 'temporaryGoalId'>): Promise<void> {
-    const binding = this.binding(owner, agent.id)
-    if (!binding || binding.status !== 'active' && !binding.pendingRemoval) return
-    if (!this.#configuration) {
-      // Disabled mode preserves the remote data, but this local Bot can no longer reach it.
-      this.saveBinding({ ...binding, status: 'removed', pendingRemoval: true, updatedAt: Date.now() })
-      return
-    }
-    try {
-      await this.adminClient().adminRemoveUser(this.#configuration.accountId, binding.userId)
-    } catch (error) {
-      if (!(error instanceof OpenVikingError) || ![404, 'NOT_FOUND'].includes(error.statusCode ?? error.code)) mapError(error, 'user 删除')
-    }
-    this.saveBinding({ ...binding, status: 'removed', pendingRemoval: false, updatedAt: Date.now() })
+    const namespace=this.namespace,key=`${namespace}:${owner}:${agent.id}`
+    const existing=this.#removals.get(key)
+    if(existing)return existing
+    const registrations=[...this.#registrations.entries()].filter(([registrationKey])=>registrationKey.endsWith(`:${owner}:${agent.id}`)).map(([,registration])=>registration)
+    const config=this.#configuration,admin=config?this.adminClient():undefined
+    const pending=Promise.resolve().then(async()=>{
+      // Registration can already have reached the remote server. Finish it before
+      // removal so its late receipt cannot recreate the deleted user's binding.
+      await Promise.allSettled(registrations)
+      // Keep the deletion attached to the configuration captured at admission.
+      const legacy=this.store.get<OpenVikingUserBinding>(owner,'openviking-binding',agent.id)
+      const binding=config
+        ? this.store.get<OpenVikingUserBinding>(owner,'openviking-binding',`${namespace}:${agent.id}`)??(legacy&&(!legacy.namespace||legacy.namespace===namespace)?legacy:undefined)
+        : legacy
+      if(binding?.status==='removed'&&!binding.pendingRemoval)return
+      const removed:OpenVikingUserBinding={...(binding??{provider:'openviking',owner,agentId:agent.id,userId:openVikingUserId(agent.id),encryptedUserKey:'',namespace}),status:'removed',pendingRemoval:true,updatedAt:Date.now()}
+      this.saveBinding(removed)
+      if(!config||!admin)return // Keep a durable pending removal while disabled.
+      try{await admin.adminRemoveUser(config.accountId,removed.userId)}
+      catch(error){if(!(error instanceof OpenVikingError)||!(error.statusCode===404||error.code==='NOT_FOUND'))mapError(error,'user 删除')}
+      this.saveBinding({...removed,pendingRemoval:false,updatedAt:Date.now()})
+    })
+    this.#removals.set(key,pending)
+    try{await pending}finally{this.#removals.delete(key)}
   }
 }
 

+ 3 - 3
src/server/runnerHub.ts

@@ -200,9 +200,9 @@ export class RunnerHub {
           let closed=false
           return {rpc:async(method,params)=>{
             if(closed)throw new Error('Runner gateway closed')
-            const cleanup=['session.interrupt','session.close'].includes(method)
+            const cleanup=['session.interrupt','session.close','session.active_list'].includes(method)
             if(method==='computer.transfer'&&!this.online.get(record.id)?.features.includes('file-transfer-v1'))throw new HttpError(409,'请更新 Runner 以使用虚拟机文件传输','computer_transfer_upgrade_required')
-            if(scope?.cleanupOnly&&(!['session.resume','session.interrupt','session.close'].includes(method)||(method==='session.resume'&&params.session_id!==scope.sessionId)))throw new HttpError(403,'清理通道只能操作原绑定会话','runner_cleanup_forbidden')
+            if(scope?.cleanupOnly&&(!['session.resume','session.interrupt','session.close','session.active_list'].includes(method)||(method==='session.resume'&&params.session_id!==scope.sessionId)))throw new HttpError(403,'清理通道只能操作原绑定会话','runner_cleanup_forbidden')
             if(!cleanup&&!valid())throw new HttpError(403,'本轮执行授权已失效','runner_command_not_admitted')
             if(!scope?.cleanupOnly&&(method==='session.create'||method==='session.resume'))requireProfile(String(params.profile??'default'))
             return this.request(record.id,'gateway.rpc',{connectionId:id,method,params})
@@ -380,7 +380,7 @@ export class RunnerHub {
       if(match[2]==='admit') {
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
         let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()&&this.networkCommandAllowed(record.id,command.kind,command.payload)
-        if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open'){const connection=this.connections.get(String(command.payload.connectionId));allowed&&=connection?.runnerId===record.id&&(command.kind==='gateway.rpc'&&['session.interrupt','session.close'].includes(String(command.payload.method))||connection.valid())}
+        if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open'){const connection=this.connections.get(String(command.payload.connectionId));allowed&&=connection?.runnerId===record.id&&(command.kind==='gateway.rpc'&&['session.interrupt','session.close','session.active_list'].includes(String(command.payload.method))||connection.valid())}
         if(command?.kind==='lease.create'||command?.kind==='lease.bind') {
           const lease=this.leases.get(String(command.payload.leaseId))
           try {if(lease?.runnerId!==record.id)allowed=false;else lease.input.assertActive()}catch{allowed=false}

+ 22 - 0
src/server/sessionStop.ts

@@ -0,0 +1,22 @@
+import { setTimeout as delay } from 'node:timers/promises'
+import { HttpError } from './errors.js'
+
+/** Interrupt acknowledgement only requests cancellation; native tools may still be running. */
+export async function waitForHermesSessionIdle(
+  rpc: (method: string, params: Record<string, unknown>) => Promise<any>,
+  sessionId: string,
+  profile: string,
+  options: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number } = {},
+): Promise<void> {
+  const deadline = Date.now() + (options.timeoutMs ?? 20_000)
+  for (;;) {
+    options.signal?.throwIfAborted()
+    const state = await rpc('session.active_list', { profile, current_session_id: sessionId })
+    if (!Array.isArray(state?.sessions)) throw new HttpError(502, 'Hermes 停止状态无效,原执行仍需核对', 'session_stop_unconfirmed')
+    // Finalized sessions disappear from Hermes' live list.
+    const session = state.sessions.find((item: { id?: string }) => item.id === sessionId)
+    if (!session || session.status === 'idle') return
+    if (Date.now() >= deadline) throw new HttpError(409, 'Hermes 原任务尚未停止,请等待执行结束后重试', 'session_still_stopping')
+    await delay(options.pollMs ?? 100, undefined, { signal: options.signal })
+  }
+}

+ 2 - 1
src/server/taskCoordinator.ts

@@ -233,6 +233,7 @@ export class WorkspaceTaskCoordinator {
       throw new HttpError(403,'恢复任务需要当前用户的新指令和有效管理员权限','goal_resume_forbidden')
     return this.store.command(owner,body.requestId,{actorId,operation:'goal.resume',goalId:goal.id,origin},()=>{
       if(goal.status==='cancelling')throw new HttpError(409,'旧执行尚未确认结束,请先核对状态','goal_still_running')
+      if(this.store.hasPendingCleanup(owner,{conversationTaskId:goal.id}))throw new HttpError(409,'旧执行尚未确认结束,请先核对状态','goal_still_running')
       if(!['blocked','waiting','cancelled'].includes(goal.status))throw new HttpError(409,'只能恢复受阻、等待或已停止的目标','goal_not_resumable')
       if(this.store.list<Run>(owner,'run').some(r=>r.conversationId===goal.conversationId&&r.conversationTaskId===goal.id&&r.id!==source.id&&!terminalRun(r.status)))
         throw new HttpError(409,'旧执行尚未确认结束,请先核对状态','goal_still_running')
@@ -291,7 +292,7 @@ export class WorkspaceTaskCoordinator {
       if(!helper.archived&&!terminalGoal(goal.status)&&helper.helperActivation===(goal.activation??1))continue
       if(!helper.archived){helper.archived=true;helper.revision++;helper.updatedAt=Date.now();helper.retiredAt=Date.now();helper.cleanupState='pending';this.store.put(owner,'agent',helper.id,helper);this.store.event(owner,'agent.changed',helper)}
       if(!helper.cleanupState){helper.cleanupState='pending';helper.retiredAt??=Date.now();this.store.put(owner,'agent',helper.id,helper)}
-      const works=this.store.list<{agentId:string;runId:string;status:string}>(owner,'turn').filter(work=>work.agentId===helper.id&&!terminalRun(work.status))
+      const works=this.store.list<{agentId:string;runId:string;status:string;cleanupPending?:boolean}>(owner,'turn').filter(work=>work.agentId===helper.id&&(!terminalRun(work.status)||work.cleanupPending))
       if(works.length){for(const runId of new Set(works.map(work=>work.runId)))void this.runtime.stop(owner,runId).catch(()=>{});continue}
       if(helper.cleanupState==='complete')continue
       const key=`${owner}:${helper.id}`

+ 11 - 9
src/server/workspaceRoutes.ts

@@ -186,8 +186,7 @@ export function workspaceRouter(
   })
   router.delete('/api/app/agents/:id', async (ctx) => {
     const user = owner(ctx)
-    await runtime.cleanupAgent(user, ctx.params.id)
-    store.deleteAgent(user, ctx.params.id)
+    await runtime.deleteAgent(user, ctx.params.id)
     ctx.body = { ok: true }
   })
   router.get('/api/app/agents/:id/usage', (ctx) => {
@@ -223,13 +222,16 @@ export function workspaceRouter(
   router.post('/api/app/conversations/:id/lifecycle', async ctx => {
     const input = parse(z.object({ action: z.enum(['archive', 'restore', 'delete']), confirmationToken: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict(), body(ctx))
     if (input.action !== 'restore' && !input.confirmationToken) throw new HttpError(409, '请先确认聊天操作', 'lifecycle_confirmation_required')
-    const user = owner(ctx), conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
-    if (runtime.openViking?.enabled && input.action === 'delete' && conversation.kind === 'direct') {
-      const agent = store.require<WorkspaceAgent>(user, 'agent', conversation.memberIds[0]!)
-      store.changeConversationLifecycle(user, conversation.id, 'archive', input.confirmationToken)
-      await runtime.cleanupAgent(user, agent.id)
-      store.deleteAgent(user, agent.id)
-    } else store.changeConversationLifecycle(user, conversation.id, input.action, input.confirmationToken)
+    const user = owner(ctx), conversation = store.get<WorkspaceConversation>(user, 'conversation', ctx.params.id)
+    if(!conversation&&input.action==='delete'){
+      const deletion=store.list<{id:string;conversationIds:string[]}>(user,'agent-deletion').find(item=>item.conversationIds.includes(ctx.params.id))
+      if(deletion)await runtime.deleteAgent(user,deletion.id)
+      else store.require(user,'conversation',ctx.params.id)
+    }else{
+      const current=conversation??store.require<WorkspaceConversation>(user,'conversation',ctx.params.id)
+      store.changeConversationLifecycle(user,current.id,input.action,input.confirmationToken)
+      if(input.action==='delete'&&current.kind==='direct')await runtime.cleanupAgent(user,current.memberIds[0]!)
+    }
     runtime.wake()
     ctx.body = { ok: true }
   })

+ 48 - 4
src/server/workspaceRuntime.ts

@@ -7,6 +7,7 @@ import type {BrowserUpload} from '../runner/browser/types.js'
 import {type DesktopEnvironments} from './desktopEnvironments.js'
 import {type GrokCloud} from './grokCloud.js'
 import {VmToolSession} from './vmComputer.js'
+import {waitForHermesSessionIdle} from './sessionStop.js'
 import { randomUUID } from 'node:crypto'
 import { setTimeout as delay } from 'node:timers/promises'
 import { readFileSync } from 'node:fs'
@@ -127,7 +128,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   private operationJournal:ToolOperationJournal
   assertCanSubmit:()=>void=()=>{}
   get idleForUpdate(): boolean {
-    return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()
+    return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND (COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') OR coalesce(json_extract(data,'$.cleanupPending'),0)=1) LIMIT 1").get()
   }
   desktopEnvironments?:DesktopEnvironments
   cloud?:GrokCloud
@@ -135,6 +136,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   computerAvailable?:(owner:string,agent:Agent)=>boolean
   inspector?:import('./workspaceInspector.js').WorkspaceInspector
   private live = new Map<string, LiveTurn>()
+  private memoryCleanup?: Promise<void>
+  private memoryCleanupRetries=new Map<string,{attempt:number;after:number}>()
   readonly teamTools: WorkspaceTeamTools
   routineTools?: WorkspaceRoutineTools
   readonly tasks: WorkspaceTaskCoordinator
@@ -146,6 +149,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     super(store, userActive)
     this.operationJournal=new ToolOperationJournal(store)
     this.openViking = openViking
+    if(openViking)openViking.onConfigurationChanged=()=>{if(!this.closing)this.wake()}
     this.teamTools = new WorkspaceTeamTools(store, nodes, this)
     this.tasks = new WorkspaceTaskCoordinator(store, this)
     this.knowledge = new WorkspaceKnowledge(store.home, store, openViking)
@@ -160,9 +164,43 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     if (!this.openViking?.enabled) return
     await this.openViking.ensureUser(owner, { id, temporaryGoalId: undefined })
   }
+  protected override maintenance(): boolean {
+    if(!this.openViking?.enabled)return false
+    const pending=new Map<string,{owner:string;id:string}>()
+    for(const owner of this.store.owners()){
+      for(const item of this.store.list<{id:string;memoryCleanupPending?:boolean}>(owner,'agent-deletion'))
+        if(item.memoryCleanupPending)pending.set(`${owner}:${item.id}`,{owner,id:item.id})
+      for(const binding of this.store.list<{agentId:string;namespace:string;status:string;pendingRemoval?:boolean}>(owner,'openviking-binding'))
+        if(binding.namespace===this.openViking.namespace&&binding.status==='removed'&&binding.pendingRemoval)
+          pending.set(`${owner}:${binding.agentId}`,{owner,id:binding.agentId})
+    }
+    if(!this.memoryCleanup){
+      const next=[...pending.entries()].find(([key])=>(this.memoryCleanupRetries.get(key)?.after??0)<=Date.now())
+      if(next){
+        const [key,{owner,id}]=next
+        this.memoryCleanup=this.cleanupAgent(owner,id).then(()=>{this.memoryCleanupRetries.delete(key)})
+          .catch(error=>{
+            if(this.closing)return
+            const attempt=(this.memoryCleanupRetries.get(key)?.attempt??0)+1
+            this.memoryCleanupRetries.set(key,{attempt,after:Date.now()+Math.min(30_000,500*2**Math.min(attempt-1,6))})
+            const deletion=this.store.get<{id:string;memoryCleanupPending:boolean;cleanupError?:string}>(owner,'agent-deletion',id)
+            if(deletion){deletion.cleanupError=error instanceof Error?error.message:'远端记忆清理失败';this.store.put(owner,'agent-deletion',id,deletion)}
+          }).finally(()=>{this.memoryCleanup=undefined;this.wake()})
+      }
+    }
+    return pending.size>0
+  }
   async cleanupAgent(owner: string, id: string): Promise<void> {
-    if (!this.openViking) return
-    await this.openViking.removeUser(owner, { id, temporaryGoalId: undefined })
+    await this.openViking?.removeUser(owner, { id, temporaryGoalId: undefined })
+    const deletion=this.store.get<{id:string;memoryCleanupPending:boolean;cleanupError?:string}>(owner,'agent-deletion',id)
+    if(deletion){deletion.memoryCleanupPending=this.openViking?.binding(owner,id)?.pendingRemoval===true;delete deletion.cleanupError;this.store.put(owner,'agent-deletion',id,deletion)}
+  }
+  async deleteAgent(owner: string, id: string): Promise<void> {
+    // Commit all local validation/deletion before any destructive remote action.
+    // The durable tombstone also makes a repeated DELETE retry remote cleanup.
+    if(this.store.get(owner,'agent',id))this.store.deleteAgent(owner,id)
+    else if(!this.store.get(owner,'agent-deletion',id))this.store.require(owner,'agent',id)
+    try{await this.cleanupAgent(owner,id)}finally{this.wake()}
   }
   send(owner: string, conversationId: string, input: unknown): Run {
     return this.submit(owner, conversationId, input)
@@ -214,6 +252,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       if (projectId) for (const id of source?.targetAgentId ? [source.targetAgentId] : c.memberIds) this.knowledge.requireProjectMember(owner, projectId, id)
       const conversationTask = this.store.resolveTask(owner, conversationId, body.taskId),
         conversationTaskId = conversationTask?.id
+      if(this.store.hasPendingCleanup(owner,{conversationId,conversationTaskId}))throw new HttpError(409,'原执行尚未确认停止,请等待停止完成后再发消息','conversation_stopping')
       if (conversationTaskId) {
         const alreadyActive = this.store.list<Run>(owner, 'run').some(run => run.conversationTaskId === conversationTaskId && !['complete', 'failed', 'interrupted'].includes(run.status))
         if (!alreadyActive && this.store.activeTaskCount(owner, conversationId) >= WORKSPACE_CONCURRENCY_LIMIT)
@@ -1088,7 +1127,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const key = this.bindingKey(work.conversationId, work.conversationTaskId, work.agentId)
     const live = this.live.get(key)
     if (live?.taskId === work.id) {
-      try { await live.gateway.rpc('session.interrupt', { session_id: live.runtimeId }) }
+      try {
+        await live.gateway.rpc('session.interrupt', { session_id: live.runtimeId })
+        const binding=this.store.require<WorkspaceBinding>(owner,'binding',key)
+        if(binding.execution!=='computer')await waitForHermesSessionIdle((method,params)=>live.gateway.rpc(method,params),live.runtimeId,binding.profile)
+      }
       catch (error) { if (!live.gateway.connected) live.done(new Error('已停止')); throw error }
       const current = this.getWork(owner, work.id)
       if (['complete', 'failed', 'interrupted'].includes(current.status)) {
@@ -1110,6 +1153,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           await gateway.connect()
           const opened = await gateway.rpc('session.resume', { profile: binding.profile, session_id: binding.storedId, omit_messages: true, close_on_disconnect: false,...(binding.execution==='computer'?{recoverOnly:true}:{}) })
           if (opened.running) await gateway.rpc('session.interrupt', { session_id: opened.session_id })
+          if(binding.execution!=='computer')await waitForHermesSessionIdle((method,params)=>gateway.rpc(method,params),String(opened.session_id),binding.profile)
         } finally { gateway.close() }
       }
       const current = this.getWork(owner, work.id)

+ 4 - 1
src/server/workspaceScheduler.ts

@@ -156,6 +156,8 @@ export abstract class WorkspaceScheduler {
     this.updateRoot(owner, root.id)
   }
   start(): void { this.wake() }
+  /** Maintenance uses the same bounded retry loop as durable turn cleanup. */
+  protected maintenance(): boolean { return false }
   wake(): void {
     if (this.closing || this.wakePending) return
     this.wakePending = true
@@ -307,6 +309,7 @@ export abstract class WorkspaceScheduler {
     if (this.pumping || this.closing) return
     this.pumping = true
     try {
+      if(this.maintenance())this.retrySoon()
       let all = this.store.owners().flatMap(owner => this.pendingWorks(owner).map(work => ({ owner, work })))
       for (const { owner, work } of all) {
         if (terminal(work.status) && work.cleanupPending) this.scheduleInterruptCleanup(owner, work)
@@ -360,7 +363,7 @@ export abstract class WorkspaceScheduler {
         if (work.status === 'queued' && (c.archived || root.stopRequested || !this.store.taskMemberIds(owner,c,work.conversationTaskId).includes(work.agentId))) {
           work.status = 'interrupted'; work.error = '执行前成员已移除或聊天已停止'; this.saveWork(owner, work); this.wake(); continue
         }
-        const occupied = all.map(entry => this.getWork(entry.owner, entry.work.id)).filter(t => (['running', 'waiting', 'uncertain'].includes(t.status) || this.executing.has(t.id)) && t.id !== work.id)
+        const occupied = all.map(entry => this.getWork(entry.owner, entry.work.id)).filter(t => (t.cleanupPending || ['running', 'waiting', 'uncertain'].includes(t.status) || this.executing.has(t.id)) && t.id !== work.id)
         // Only the conversation/task/Agent binding shares mutable session state.
         // Other sessions may run concurrently; computer leases and tool queues
         // continue to serialize access to their actual shared resources.

+ 13 - 2
src/server/workspaceStore.ts

@@ -421,6 +421,7 @@ export class WorkspaceStore {
     if(body.vmExecution==='profile')body.allowHostEnvironment=true
     if (origin?.createdByAgentId) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     return this.atomic(() => {
+      if (this.get(owner, 'agent-deletion', allocatedId)) throw new HttpError(409, '已删除的 Bot 编号不能复用', 'agent_deleted')
       if (
         this.list<Agent>(owner, 'agent').some(
           (a) => a.name.toLocaleLowerCase() === body.name.toLocaleLowerCase(),
@@ -517,13 +518,13 @@ export class WorkspaceStore {
       const hostPermissionChanged=patch.allowHostEnvironment!==undefined&&patch.allowHostEnvironment!==(agent.allowHostEnvironment===true)
       const destinationChanged=desktopHostChanged||hostPermissionChanged||(patch.vmExecution!==undefined&&patch.vmExecution!==(agent.vmExecution??'worker'))||(patch.computer!==undefined&&patch.computer!==agent.computer)||(patch.browserProfile!==undefined&&patch.browserProfile!==(agent.browserProfile??'persistent'))
       if(sourceChanged||destinationChanged){
-        if(this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['queued','running','waiting','uncertain','cancelling'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再修改机器人来源或电脑','agent_execution_busy')
+        if(this.list<{agentId:string;status:string;cleanupPending?:boolean}>(owner,'turn').some(work=>work.agentId===id&&(work.cleanupPending||['queued','running','waiting','uncertain','cancelling'].includes(work.status))))throw new HttpError(409,'请先确认当前任务停止,再修改机器人来源或电脑','agent_execution_busy')
         if(this.list<{owner:string;agentId:string;environmentId?:string;expiresAt:number}>('_system','computer-control').some(grant=>grant.owner===owner&&grant.expiresAt>Date.now()&&(grant.agentId===id||grant.environmentId===(agent.computerEnvironmentId??id))))throw new HttpError(409,'请先交还电脑控制权','agent_execution_busy')
         if(sourceChanged&&agent.computerEnvironmentId){const shared=this.require<{nodeId:string;profile:string;managedLocalVm?:boolean;managedCompose?:boolean}>(owner,'shared-computer',agent.computerEnvironmentId);if((patch.nodeId??agent.nodeId)!==shared.nodeId||(!shared.managedLocalVm&&!shared.managedCompose&&shared.profile!=='*'&&(patch.profile??agent.profile)!==shared.profile))throw new HttpError(409,'请先解除电脑共享,再切换到其他来源','shared_computer_active')}
       }
       if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
       if(agent.computerEnvironmentId&&patch.execution==='profile'&&(patch.envs?.vm??agent.envs?.vm)!==true)throw new HttpError(409,'请先解除电脑共享,再切换执行环境','shared_computer_active')
-      if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
+      if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string;cleanupPending?:boolean}>(owner,'turn').some(work=>work.agentId===id&&(work.cleanupPending||['running','waiting','uncertain'].includes(work.status))))throw new HttpError(409,'请先确认当前任务停止,再切换执行环境','agent_execution_busy')
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived' && key !== 'approvalPolicy'))
         throw new HttpError(409, '引用机器人的配置由远端管理', 'remote_agent_read_only')
       if (
@@ -567,12 +568,17 @@ export class WorkspaceStore {
     }
   }
   private requireConversationIdle(owner: string, id: string): void {
+    if (this.hasPendingCleanup(owner, {conversationId:id})) throw new HttpError(409, '聊天原执行尚未确认停止,请稍后重试', 'conversation_stopping')
     if (['run', 'turn'].some(kind => this.list<{ conversationId: string; status: string }>(owner, kind).some(run => run.conversationId === id && !['complete', 'failed', 'interrupted', 'skipped'].includes(run.status))))
       throw new HttpError(409, '聊天仍有任务未结束,请停止任务后重试', 'conversation_running')
     if (this.list<{ conversationId: string; origin?: { conversationId: string }; status: string }>(owner, 'goal').some(goal =>
       (goal.conversationId === id || goal.origin?.conversationId === id) && !['complete', 'blocked', 'cancelled'].includes(goal.status)))
       throw new HttpError(409, '聊天仍有关联任务未结束,请停止任务后重试', 'conversation_running')
   }
+  hasPendingCleanup(owner: string, scope: {agentId?: string; conversationId?: string; conversationTaskId?: string}): boolean {
+    return this.list<{agentId:string;conversationId:string;conversationTaskId?:string;cleanupPending?:boolean}>(owner,'turn')
+      .some(work => work.cleanupPending && Object.entries(scope).every(([key,value]) => work[key as keyof typeof work] === value))
+  }
   changeConversationLifecycle(owner: string, id: string, action: WorkspaceLifecycleAction, confirmationToken?: string): void {
     this.atomic(() => {
       const conversation = this.require<Conversation>(owner, 'conversation', id)
@@ -590,6 +596,7 @@ export class WorkspaceStore {
       if (preview.groups.length && !confirmationToken)
         throw new HttpError(409, '此 Bot 仍是群聊成员,请确认退出群聊后再归档或删除', 'agent_in_group')
       if (agent?.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
+      if (agent && this.hasPendingCleanup(owner, {agentId:agent.id})) throw new HttpError(409, 'Bot 原执行尚未确认停止,请稍后重试', 'agent_stopping')
       if (agent && this.list<{ agentId: string; status: string }>(owner, 'turn').some(turn => turn.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(turn.status)))
         throw new HttpError(409, 'Bot 仍有任务未结束,请停止任务后重试', 'agent_running')
       for (const conversationId of [id, ...preview.groups.map(group => group.id)]) this.requireConversationIdle(owner, conversationId)
@@ -607,6 +614,7 @@ export class WorkspaceStore {
     })
   }
   private detachAgentComputer(owner: string, agent: Agent): void {
+    if (this.hasPendingCleanup(owner, {agentId:agent.id})) throw new HttpError(409, 'Bot 原执行尚未确认停止,请稍后重试', 'agent_stopping')
     const environmentId = agent.computerEnvironmentId
     if (!environmentId) return
     if (this.list<{ agentId: string; status: string }>(owner, 'turn').some(work => work.agentId === agent.id && !['complete', 'failed', 'interrupted', 'skipped'].includes(work.status)))
@@ -629,6 +637,7 @@ export class WorkspaceStore {
       const agent = this.require<Agent>(owner, 'agent', id)
       if (!agent.archived) throw new HttpError(409, '请先归档 Bot,再删除', 'agent_not_archived')
       if (agent.temporaryGoalId) throw new HttpError(409, '临时助手由所属任务管理', 'helper_task_bound')
+      if (this.hasPendingCleanup(owner, {agentId:id})) throw new HttpError(409, 'Bot 原执行尚未确认停止,请稍后重试', 'agent_stopping')
       const conversations = this.list<Conversation>(owner, 'conversation')
       if (conversations.some(c => c.kind === 'group' && c.memberIds.includes(id)))
         throw new HttpError(409, '此 Bot 仍是群聊成员,请先从群聊移除或删除相关群聊', 'agent_in_group')
@@ -643,6 +652,7 @@ export class WorkspaceStore {
         this.put(owner, 'bot-mcp-plugin', plugin.id, { ...plugin, agentIds: plugin.agentIds.filter(agentId => agentId !== id), revision: plugin.revision + 1 })
       }
       this.remove(owner, 'agent', id)
+      this.put(owner, 'agent-deletion', id, {id,conversationIds:conversations.filter(c=>c.kind==='direct'&&c.memberIds[0]===id).map(c=>c.id),deletedAt:Date.now(),memoryCleanupPending:true})
       this.event(owner, 'agent.deleted', { id })
     })
   }
@@ -925,6 +935,7 @@ export class WorkspaceStore {
   deleteTask(owner: string, conversationId: string, taskId: string): void {
     this.atomic(() => {
       const task = this.requireTask(owner, conversationId, taskId)
+      if (this.hasPendingCleanup(owner, {conversationTaskId:taskId})) throw new HttpError(409, '任务原执行尚未确认停止,暂时无法移除', 'workspace_task_stopping')
       if (this.list<Run>(owner, 'run').some(run => run.conversationTaskId === taskId && !['complete', 'failed', 'interrupted'].includes(run.status)))
         throw new HttpError(409, '任务仍在运行,暂时无法移除', 'workspace_task_running')
       const messageIds = new Set(this.list<Message>(owner, 'message').filter(message => message.conversationTaskId === taskId).map(message => message.id))

+ 40 - 0
tests/server/openViking.test.ts

@@ -95,6 +95,46 @@ describe('OpenViking configuration', () => {
 })
 
 describe('OpenViking memory provider', () => {
+  it('serializes removal after in-flight registration and never resurrects a removed user',async()=>{
+    let release!:(value:{user_key:string})=>void
+    admin.adminRegisterUser.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
+    const registration=service.ensureUser(owner,a)
+    const removal=service.removeUser(owner,a)
+    await expect(service.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
+    expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+    release({user_key:'fixture-racing-key'})
+    await registration;await removal
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
+    await expect(service.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
+    expect(admin.adminRegisterUser).toHaveBeenCalledTimes(1)
+    expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
+  })
+  it('persists failed removal for an idempotent retry after restarting the service',async()=>{
+    await service.ensureUser(owner,a)
+    admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture offline'))
+    await expect(service.removeUser(owner,a)).rejects.toMatchObject({code:'openviking_unavailable'})
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:true})
+    const restarted=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
+    await restarted.removeUser(owner,a);await restarted.removeUser(owner,a)
+    expect(restarted.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
+    expect(admin.adminRemoveUser).toHaveBeenCalledTimes(2)
+    await expect(restarted.ensureUser(owner,a)).rejects.toMatchObject({code:'openviking_user_removed'})
+  })
+  it('does not lose removal when configuration is disabled during registration',async()=>{
+    let release!:(value:{user_key:string})=>void
+    admin.adminRegisterUser.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
+    const registration=service.ensureUser(owner,a)
+    service.configure(undefined)
+    const removal=service.removeUser(owner,a)
+    release({user_key:'fixture-register-before-disabled'})
+    await registration;await removal
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:true})
+    expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+    service.configure(manager.configuration())
+    await service.removeUser(owner,a)
+    expect(service.binding(owner,a.id)).toMatchObject({status:'removed',pendingRemoval:false})
+    expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
+  })
   it('reports writes, duplicates and idempotent replays without changing the public memory shape', async () => {
     const input = { requestId: randomUUID(), scope: 'agent' as const, agentId: a.id, content: '开发环境为 studio', tier: 'log' as const }
     const first = await knowledge.writeMemoryWithResult(owner, input)

+ 58 - 2
tests/server/profileComputer.test.ts

@@ -14,9 +14,11 @@ import {ProfileSkillSession} from '../../src/runner/worker/skills'
 import {UpstreamClient} from '../../src/server/upstream'
 import {UpstreamServiceSession} from '../../src/server/localAuth'
 import type {GatewayFrame} from '../../src/server/workspaceGateway'
+import * as sessionStop from '../../src/server/sessionStop'
+import {HttpError} from '../../src/server/errors'
 
 async function fixture(hermesManaged=false,bridgeVersion=2){
-  const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(':memory:')
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-profile-computer-')),db=new DatabaseSync(join(home,'runner.sqlite'))
   const vmRoot=join(home,'vm'),hermesRoot=join(home,'hermes');await mkdir(vmRoot);await mkdir(hermesRoot);await writeFile(join(hermesRoot,'input.txt'),'hermes-authorized-file')
   const vmFiles=new FileTransferFiles(vmRoot),hermesFiles=new FileTransferFiles(hermesRoot)
   const calls:Array<{method:string;params:any}>=[],bindings:any[]=[],frames:GatewayFrame[]=[],gateways:ComputerGateway[]=[]
@@ -81,7 +83,7 @@ async function fixture(hermesManaged=false,bridgeVersion=2){
     expect(found,JSON.stringify(catalog)).toBeDefined()
     return post('/tools/call',{toolId:found.id,arguments:args,callId:randomUUID()})
   }
-  return {home,vmRoot,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,
+  return {home,vmRoot,runtime,resolver,execute,meta,gateway,calls,frames,bindings,sessions,post,tool,emit,target,
     setInterruptStops(value:boolean){interruptStops=value},revoke(){allowed=false},
     async close(){
       interruptStops=true;for(const session of sessions.values())session.running=false
@@ -173,6 +175,60 @@ it('waits for native termination and VM calls before human takeover, then resume
   }finally{await f.close()}
 })
 
+it('keeps the holder and durable stop barrier on timeout or close, and retries the same gateway safely',async()=>{
+  const f=await fixture()
+  try{
+    const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default'})
+    await g.rpc('prompt.submit',{session_id:session.session_id,text:'Work until stopped'})
+    await f.tool('computer_shell',{command:'pwd'})
+    const holder=g.lease!.holderId,stored=f.runtime.session(session.session_id,f.meta,'default').profileSessionId
+    f.setInterruptStops(false)
+    const timeout=vi.spyOn(sessionStop,'waitForHermesSessionIdle').mockRejectedValue(new HttpError(409,'native stop timeout','session_still_stopping'))
+    await expect(g.rpc('session.interrupt',{session_id:session.session_id})).rejects.toMatchObject({code:'session_still_stopping'})
+    expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).toContain(holder)
+    expect(f.runtime.session(session.session_id,f.meta,'default')).toMatchObject({stopPending:true,profileSessionId:stored})
+    expect(()=>f.runtime.pool.renew(g.lease!)).not.toThrow()
+    await expect(g.rpc('prompt.submit',{session_id:session.session_id,text:'forbidden retry'})).rejects.toMatchObject({code:'computer_cancelled'})
+    await expect(g.takeControl(randomUUID(),()=>{})).rejects.toMatchObject({code:'computer_cancelled'})
+    await expect(f.runtime.controls.take(f.meta,'default',randomUUID(),async()=>{})).rejects.toMatchObject({code:'computer_stopping'})
+    const next=f.gateway();await next.connect()
+    await expect(next.rpc('session.create',{profile:'default',toolsOnly:true})).rejects.toMatchObject({code:'computer_stopping'})
+    await expect(g.close()).rejects.toMatchObject({code:'session_still_stopping'})
+    expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).toContain(holder)
+    expect(f.runtime.gateways.get(f.meta.environmentId)?.has(g)).toBe(true)
+    timeout.mockRestore();f.setInterruptStops(true)
+    await g.interrupt();await g.close()
+    expect(f.runtime.session(session.session_id,f.meta,'default').stopPending).toBe(false)
+    expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.holderIds).not.toContain(holder)
+    expect(f.calls.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
+  }finally{await f.close()}
+})
+
+it('restores a persisted stop barrier and clears it only through cleanup resume without submitting again',async()=>{
+  const f=await fixture()
+  try{
+    const g=f.gateway();await g.connect();const opened=await g.rpc('session.create',{profile:'default'})
+    await g.rpc('prompt.submit',{session_id:opened.session_id,text:'original work'})
+    await f.tool('computer_shell',{command:'pwd'})
+    const persisted=f.runtime.session(opened.session_id,f.meta,'default')
+    await g.close()
+    // Crash checkpoint: native work stopped, but its durable cleanup receipt
+    // was not committed yet. Re-open the real temporary sqlite file.
+    persisted.stopPending=true;f.runtime.save(persisted)
+    const db=new DatabaseSync(join(f.home,'runner.sqlite'))
+    const restarted=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:f.target.url.href,hermesURL:f.target.url.href,token:'test',allowedProfiles:['default'],artifactRoots:[],computers:f.runtime.config},f.home)
+    const recovering=new ComputerGateway(restarted,f.meta,g.workId,async()=>{},async()=>({}),f.target)
+    try{
+      await restarted.ready;await recovering.connect()
+      await expect(recovering.rpc('session.resume',{profile:'default',session_id:opened.session_id})).rejects.toMatchObject({code:'computer_stopping'})
+      await recovering.rpc('session.resume',{profile:'default',session_id:opened.session_id,recoverOnly:true})
+      expect(restarted.session(opened.session_id,f.meta,'default').stopPending).toBe(false)
+      expect(f.calls.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
+      expect(f.calls.findLast(call=>call.method==='session.resume')?.params.session_id).toBe(persisted.profileSessionId)
+    }finally{await recovering.close();await restarted.controls.close();await restarted.pool.close();db.close()}
+  }finally{await f.close()}
+})
+
 
 it('uses Hermes for an isolated Bot without reading Profile config or running a Worker; native skills and authorized files stay in Hermes',async()=>{
   const f=await fixture(true)

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

@@ -47,7 +47,7 @@ beforeEach(async()=>{
     sockets.push(socket);socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
     socket.on('message',raw=>{
       const value=JSON.parse(String(raw));rpc.push(value)
-      const result=['session.create','session.resume'].includes(value.method)?{session_id:randomUUID(),stored_session_id:randomUUID(),info:{profile_name:value.params.profile}}:{ok:true}
+      const result=['session.create','session.resume'].includes(value.method)?{session_id:randomUUID(),stored_session_id:randomUUID(),info:{profile_name:value.params.profile}}:value.method==='session.active_list'?{sessions:[{id:value.params.current_session_id,status:'idle',title:'owned'},{id:'unowned',status:'working',title:'private'}]}:{ok:true}
       socket.send(JSON.stringify({id:value.id,result}))
     })
   })
@@ -363,6 +363,8 @@ it('keeps only stop operations available on an existing channel after task permi
   allowed=false
   await expect(gateway.rpc('prompt.submit',{session_id:session.session_id,text:'forbidden'})).rejects.toMatchObject({code:'runner_command_not_admitted'})
   expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
+  expect(await gateway.rpc('session.active_list',{profile:'default',current_session_id:session.session_id})).toEqual({sessions:[{id:session.session_id,status:'idle'}]})
+  await expect(gateway.rpc('session.active_list',{profile:'default',current_session_id:'unowned'})).rejects.toMatchObject({code:'runner_session_forbidden'})
   expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
 })
 

+ 21 - 0
tests/server/sessionStop.test.ts

@@ -0,0 +1,21 @@
+// @vitest-environment node
+import {afterEach,expect,it,vi} from 'vitest'
+import {waitForHermesSessionIdle} from '../../src/server/sessionStop'
+
+afterEach(()=>vi.useRealTimers())
+it('waits beyond the interrupt receipt until the owned native session is idle',async()=>{
+  const rpc=vi.fn().mockResolvedValueOnce({sessions:[{id:'other',status:'idle'},{id:'mine',status:'working'}]})
+    .mockResolvedValueOnce({sessions:[{id:'mine',status:'idle'}]})
+  await waitForHermesSessionIdle(rpc,'mine','default',{pollMs:1})
+  expect(rpc).toHaveBeenCalledTimes(2)
+  expect(rpc).toHaveBeenCalledWith('session.active_list',{profile:'default',current_session_id:'mine'})
+})
+it('accepts a finalized session disappearing, but refuses malformed stop evidence',async()=>{
+  await waitForHermesSessionIdle(async()=>({sessions:[]}),'mine','default')
+  await expect(waitForHermesSessionIdle(async()=>({ok:true}),'mine','default')).rejects.toMatchObject({code:'session_stop_unconfirmed'})
+})
+it('times out without treating an acknowledgement or a sibling idle session as termination',async()=>{
+  const pending=waitForHermesSessionIdle(async()=>({sessions:[{id:'mine',status:'working'},{id:'other',status:'idle'}]}),'mine','default',{timeoutMs:200,pollMs:100})
+  const rejected=expect(pending).rejects.toMatchObject({code:'session_still_stopping'})
+  await rejected
+})

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

@@ -174,6 +174,11 @@ describe('durable team task coordination',()=>{
     expect(()=>runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)).toThrow('旧执行')
     settle(old,'interrupted','')
     await vi.waitFor(()=>expect(store.get<AgentGoal>(owner,'goal',goal.id)?.status).toBe('cancelled'))
+    const cleanupId=randomUUID()
+    store.put(owner,'turn',cleanupId,{id:cleanupId,agentId:worker.id,conversationId:team.id,conversationTaskId:goal.id,runId:old,status:'interrupted',cleanupPending:true})
+    expect(()=>runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)).toThrow('旧执行')
+    expect(()=>store.deleteTask(owner,team.id,goal.id)).toThrow('尚未确认停止')
+    store.remove(owner,'turn',cleanupId)
     const resumed=runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)
     expect(resumed.activation).toBe(2)
     expect(resumed.origin.runId).toBe(source.id)

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

@@ -9,6 +9,7 @@ import { defaultAgentIdentity, encodeAgentAvatar, decodeAgentAvatar } from '../.
 import {readHostTools,saveHostTools} from '../../src/server/hostToolSettings'
 import {DesktopEnvironments} from '../../src/server/desktopEnvironments'
 import {HttpError} from '../../src/server/errors'
+import * as sessionStop from '../../src/server/sessionStop'
 import {ExecutionSettings} from '../../src/server/executionSettings'
 import {WorkspaceAssets} from '../../src/server/workspaceAssets'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
@@ -38,6 +39,7 @@ let rejectPrompt: string | undefined
 let observeGatewayRequest: ((frame: { method: string; params: Record<string, any> }) => void) | undefined
 let rejectedModelConfig: Record<string, unknown> | undefined
 let rejectedInterrupts: number
+let nativeStillWorking:boolean
 let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
 let storedByRuntime: Map<string, string>, recoveryByStored: Map<string, Array<{ role: string; content: string }>>
 const owner = 'first-user',
@@ -54,6 +56,7 @@ beforeEach(async () => {
   observeGatewayRequest = undefined
   rejectedModelConfig = undefined
   rejectedInterrupts = 0
+  nativeStillWorking=false
   server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
   await new Promise<void>((resolve) => server.once('listening', resolve))
   const address = server.address() as { port: number }
@@ -106,6 +109,7 @@ beforeEach(async () => {
         respond({ status: 'streaming' })
         reply(socket, frame.params)
       } else if (frame.method === 'session.usage') respond({ context_used: 400, context_max: 1000 })
+      else if (frame.method === 'session.active_list') respond({sessions:[{id:frame.params.current_session_id,status:nativeStillWorking?'working':'idle'}]})
       else if (frame.method === 'session.interrupt' && rejectedInterrupts-- > 0) socket.send(JSON.stringify({id:frame.id,error:{code:500,message:'暂时不能中断'}}))
       else respond({ ok: true, status: 'interrupted' })
     })
@@ -1600,6 +1604,45 @@ it('retries a failed stop of a live task without falsely completing it or resubm
   expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
 })
 
+it('retains the stop barrier and original binding after ACK, including a restart, until native work is idle',async()=>{
+  const a=agent('真正停止'),c=direct(a.id)
+  reply=()=>{};nativeStillWorking=true
+  const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'长任务'})
+  await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
+  await runtime.stop(owner,root.id)
+  await vi.waitFor(()=>expect(requests.some(r=>r.method==='session.active_list')).toBe(true))
+  const work=store.list<Work>(owner,'turn').find(item=>item.runId===root.id)!
+  expect(store.require<Work>(owner,'turn',work.id)).toMatchObject({status:'interrupted',cleanupPending:true})
+  const original=store.require<any>(owner,'binding',`${c.id}:${a.id}`)
+  expect(()=>runtime.send(owner,c.id,{requestId:randomUUID(),content:'下一轮'})).toThrow('尚未确认停止')
+  expect(()=>store.updateAgent(owner,a.id,{archived:true})).toThrow('尚未确认停止')
+  expect(runtime.idleForUpdate).toBe(false)
+  runtime.close()
+  runtime=new WorkspaceRuntime(store,nodes,uploads)
+  nativeStillWorking=false;runtime.start()
+  await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).cleanupPending).toBe(false))
+  expect(store.require<any>(owner,'binding',`${c.id}:${a.id}`).storedId).toBe(original.storedId)
+  expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
+})
+it('retains cleanup and refuses reuse when native stop confirmation times out',async()=>{
+  const a=agent('停止超时'),c=direct(a.id)
+  reply=()=>{}
+  const root=runtime.send(owner,c.id,{requestId:randomUUID(),content:'超时长任务'})
+  await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1))
+  const confirmation=vi.spyOn(sessionStop,'waitForHermesSessionIdle').mockRejectedValueOnce(new HttpError(409,'Hermes 原任务尚未停止','session_still_stopping'))
+  try{
+    await runtime.stop(owner,root.id)
+    const work=store.list<Work>(owner,'turn').find(item=>item.runId===root.id)!
+    await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).cleanupError).toContain('原任务尚未停止'))
+    expect(store.require<Work>(owner,'turn',work.id).cleanupPending).toBe(true)
+    expect(store.get(owner,'binding',`${c.id}:${a.id}`)).toBeTruthy()
+    expect(()=>runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能复用'})).toThrow('尚未确认停止')
+    expect(()=>store.changeConversationLifecycle(owner,c.id,'delete',store.conversationLifecycle(owner,c.id).confirmationToken)).toThrow('尚未确认停止')
+    expect(runtime.idleForUpdate).toBe(false)
+  }finally{confirmation.mockRestore()}
+  await vi.waitFor(()=>expect(store.list<Work>(owner,'turn').find(item=>item.runId===root.id)?.cleanupPending).toBe(false),{timeout:3000})
+  expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(1)
+})
 it('marks a stopped administrator as interrupted without sending a false completion notification', async () => {
   const a=agent('管理员停止'),b=agent('普通成员'),g=store.createGroup(owner,{name:'停止管理员',memberIds:[a.id,b.id],administratorId:a.id})
   reply=()=>{}

+ 97 - 0
tests/server/workspaceLifecycleCleanup.test.ts

@@ -0,0 +1,97 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {OpenVikingClient} from '@openviking/sdk'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
+import {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {UploadStore} from '../../src/server/uploads'
+import {OpenVikingConfigurationManager} from '../../src/server/openVikingConfiguration'
+import {OpenVikingService} from '../../src/server/openVikingService'
+import type {WorkspaceAgent,WorkspaceConversation} from '../../src/shared/workspace'
+
+const owner='lifecycle-owner'
+let home:string,store:WorkspaceStore,uploads:UploadStore,runtime:WorkspaceRuntime,service:OpenVikingService,manager:OpenVikingConfigurationManager,bot:WorkspaceAgent,conversation:WorkspaceConversation
+const admin={adminRegisterUser:vi.fn(async()=>({user_key:'temporary-test-key'})),adminRemoveUser:vi.fn(async()=>({ok:true}))}
+const nodes={requireSource:()=>{}} as unknown as WorkspaceNodes
+beforeEach(async()=>{
+  home=mkdtempSync(join(tmpdir(),'yaoyao-cleanup-test-'));store=new WorkspaceStore(home);uploads=new UploadStore(home)
+  manager=new OpenVikingConfigurationManager(home,undefined,async()=>undefined)
+  await manager.update({enabled:true,url:'http://127.0.0.1:1933',accountId:'fixture',adminKey:'temporary-test-admin'})
+  service=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
+  runtime=new WorkspaceRuntime(store,nodes,uploads,undefined,undefined,service)
+  vi.spyOn(runtime,'wake').mockImplementation(()=>{})
+  bot=store.createAgent(owner,{name:'待删除 Bot',profile:'default'})
+  conversation=store.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
+})
+afterEach(()=>{runtime.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true});vi.clearAllMocks()})
+
+it('validates every local delete condition before removing any remote memory',async()=>{
+  await service.ensureUser(owner,bot)
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'agent_not_archived'})
+  const peer=store.createAgent(owner,{name:'群成员',profile:'default'})
+  const group=store.createGroup(owner,{name:'现有群',memberIds:[bot.id,peer.id],administratorId:peer.id})
+  store.updateAgent(owner,bot.id,{archived:true})
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'agent_in_group'})
+  expect(store.require<WorkspaceConversation>(owner,'conversation',group.id).memberIds).toContain(bot.id)
+  expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+  expect(service.binding(owner,bot.id)?.status).toBe('active')
+  expect(store.get(owner,'agent-deletion',bot.id)).toBeUndefined()
+})
+
+it('preserves the stop ledger and session binding, and blocks deletion and source reuse until cleanup',()=>{
+  store.updateAgent(owner,bot.id,{archived:true})
+  const turnId=randomUUID(),bindingId=`${conversation.id}:${bot.id}`
+  store.put(owner,'turn',turnId,{id:turnId,agentId:bot.id,conversationId:conversation.id,status:'interrupted',cleanupPending:true})
+  store.put(owner,'binding',bindingId,{id:bindingId,conversationId:conversation.id,agentId:bot.id,storedId:'original-session'})
+  expect(()=>store.deleteAgent(owner,bot.id)).toThrow('尚未确认停止')
+  expect(()=>store.updateAgent(owner,bot.id,{profile:'changed'})).toThrow('确认当前任务停止')
+  expect(()=>store.deleteConversation(owner,conversation.id,true)).toThrow('尚未确认停止')
+  expect(store.require<any>(owner,'binding',bindingId).storedId).toBe('original-session')
+  const turn=store.require<any>(owner,'turn',turnId);turn.cleanupPending=false;store.put(owner,'turn',turnId,turn)
+  store.deleteAgent(owner,bot.id)
+  expect(store.get(owner,'turn',turnId)).toBeUndefined()
+  expect(store.require<any>(owner,'agent-deletion',bot.id).conversationIds).toEqual([conversation.id])
+  expect(()=>store.createAgent(owner,{name:'复用旧 ID',profile:'default'},undefined,bot.id)).toThrow('不能复用')
+})
+
+it('keeps a failed remote deletion retryable by the same Bot id after local deletion',async()=>{
+  await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
+  admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture temporarily unavailable'))
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toMatchObject({code:'openviking_unavailable'})
+  expect(store.get(owner,'agent',bot.id)).toBeUndefined()
+  expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(true)
+  await runtime.deleteAgent(owner,bot.id);await runtime.deleteAgent(owner,bot.id)
+  expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false)
+  expect(admin.adminRemoveUser).toHaveBeenCalledTimes(2)
+})
+
+it('automatically recovers persisted remote cleanup after restart and retries transient failure',async()=>{
+  await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
+  admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture initial failure'))
+  await expect(runtime.deleteAgent(owner,bot.id)).rejects.toThrow('fixture initial failure')
+  runtime.close()
+  service=new OpenVikingService(store,manager,()=>admin as unknown as OpenVikingClient)
+  runtime=new WorkspaceRuntime(store,nodes,uploads,undefined,undefined,service)
+  admin.adminRemoveUser.mockRejectedValueOnce(new Error('fixture retry failure'))
+  runtime.start()
+  await vi.waitFor(()=>expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false),{timeout:3000})
+  expect(service.binding(owner,bot.id)).toMatchObject({status:'removed',pendingRemoval:false})
+  expect(admin.adminRemoveUser).toHaveBeenCalledTimes(3)
+  expect(admin.adminRegisterUser).toHaveBeenCalledTimes(1)
+})
+
+it('keeps cleanup durable while disabled and resumes it when the configured service returns',async()=>{
+  await service.ensureUser(owner,bot);store.updateAgent(owner,bot.id,{archived:true})
+  service.configure(undefined)
+  await runtime.deleteAgent(owner,bot.id)
+  expect(admin.adminRemoveUser).not.toHaveBeenCalled()
+  expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(true)
+  vi.mocked(runtime.wake).mockRestore()
+  service.configure(manager.configuration())
+  await vi.waitFor(()=>expect(store.require<any>(owner,'agent-deletion',bot.id).memoryCleanupPending).toBe(false))
+  expect(admin.adminRemoveUser).toHaveBeenCalledTimes(1)
+})

+ 2 - 2
tests/server/workspacePanels.test.ts

@@ -23,7 +23,7 @@ it('changes the foundation only when idle and retires old session bindings witho
  const agent=store.createAgent('owner',{name:'甲',profile:'default'}),conversation=store.list<any>('owner','conversation')[0]
  store.put('owner','binding',`${conversation.id}:${agent.id}`,{id:`${conversation.id}:${agent.id}`,storedId:'old-session'})
  store.put('owner','turn','busy',{agentId:agent.id,status:'queued'})
- expect(()=>store.updateAgent('owner',agent.id,{profile:'other'})).toThrow('先停止')
+ expect(()=>store.updateAgent('owner',agent.id,{profile:'other'})).toThrowError(expect.objectContaining({code:'agent_execution_busy'}))
  store.remove('owner','turn','busy')
  expect(store.updateAgent('owner',agent.id,{profile:'other'}).profile).toBe('other')
  expect(store.list('owner','binding')).toEqual([])
@@ -140,7 +140,7 @@ it('persists the separate Profile execution mode, checks its Runner capability a
  expect(opened).not.toHaveProperty('hostAccess')
  store.put('owner','turn','active-profile',{agentId:agent.id,status:'running'})
  await request(app.callback()).put(path).send({computer:'vm',vmExecution:'worker'}).expect(409)
- expect(()=>store.updateAgent('owner',agent.id,{vmExecution:'worker'})).toThrow('先停止当前任务')
+ expect(()=>store.updateAgent('owner',agent.id,{vmExecution:'worker'})).toThrowError(expect.objectContaining({code:'agent_execution_busy'}))
  store.remove('owner','turn','active-profile')
  const member=store.createAgent('owner',{name:'不继承模式',profile:'default',computer:'vm',execution:'computer',vmExecution:'profile'},{createdByAgentId:agent.id,createdFromRunId:'run'})
  expect(member.vmExecution).toBe('worker');expect(member.allowHostEnvironment).toBe(false)

+ 17 - 0
tests/server/workspaceRoutes.test.ts

@@ -354,6 +354,23 @@ describe('application workspace HTTP contract', () => {
     expect(store.require<WorkspaceAgent>('first', 'agent', bot.id).computerEnvironmentId).toBe(shared.id)
     expect(store.cursor('first')).toBe(cursor)
   })
+  it('retries remote cleanup through either delete endpoint after the local Bot and chat are gone',async()=>{
+    const store=runtime.workspace,bot=store.createAgent('first',{name:'远端清理重试',profile:'default'})
+    const direct=store.list<WorkspaceConversation>('first','conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===bot.id)!
+    const path=`/api/app/conversations/${direct.id}/lifecycle`,preview=(await req('get',path).expect(200)).body
+    const cleanup=vi.spyOn(runtime.workspaceRuntime,'cleanupAgent').mockRejectedValueOnce(new Error('模拟远端清理失败')).mockResolvedValue(undefined)
+    await req('delete',`/api/app/agents/${bot.id}`).expect(409)
+    expect(cleanup).not.toHaveBeenCalled()
+    await req('post',path).send({action:'delete',confirmationToken:preview.confirmationToken}).expect(500)
+    expect(store.get('first','agent',bot.id)).toBeUndefined()
+    expect(store.get('first','conversation',direct.id)).toBeUndefined()
+    expect(store.get('first','agent-deletion',bot.id)).toBeTruthy()
+    await req('post',path).send({action:'delete',confirmationToken:preview.confirmationToken}).expect(200)
+    await req('delete',`/api/app/agents/${bot.id}`).expect(200)
+    expect(cleanup).toHaveBeenCalledTimes(3)
+    await req('delete',`/api/app/agents/${bot.id}`,'another').expect(404)
+    cleanup.mockRestore()
+  })
   it('deletes an unarchived group while preserving its member Bots', async () => {
     const store = runtime.workspace
     const a = store.createAgent('first', { name: '甲', profile: 'default' }), b = store.createAgent('first', { name: '乙', profile: 'default' })

+ 1 - 1
tests/server/workspaceTeamTools.test.ts

@@ -448,7 +448,7 @@ describe('loopback tool lease', () => {
 it('legacy VM managers create members on server Hermes and cannot rebind a running Agent',async()=>{
   store.put(owner,'agent',manager.id,{...manager,execution:'computer'})
   expect((await call('create_agent',{requestId:randomUUID(),name:'统一成员',profile:'default',execution:'profile'})).agent).toMatchObject({execution:'profile',canManageTeam:true})
-  expect(()=>store.updateAgent(owner,manager.id,{execution:'profile'})).toThrow('请先停止当前任务')
+  expect(()=>store.updateAgent(owner,manager.id,{execution:'profile'})).toThrowError(expect.objectContaining({code:'agent_execution_busy'}))
 })
 it('creates a task-scoped helper once and rejects promotion or use outside its goal',async()=>{
   const {team,task}=await buildTeam()