Răsfoiți Sursa

fix(computer): 避免节点离线时任务无限等待

Samien 1 lună în urmă
părinte
comite
e339bef705

+ 3 - 3
src/server/workspaceRuntime.ts

@@ -18,7 +18,7 @@ import type {
   WorkspaceAgentUsageSummary,
 } from '../shared/workspace.js'
 
-import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK, WORKSPACE_CONCURRENCY_LIMIT } from './workspaceScheduler.js'
+import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK, WORKSPACE_CONCURRENCY_LIMIT, RESOURCE_WAIT_CODES } from './workspaceScheduler.js'
 import type { WorkspacePlugins, OpenBotPlugins } from './botPlugins/workspacePlugins.js'
 import { mentionedAgents } from './workspaceMentions.js'
 import { WorkspaceTeamTools, TEAM_TOOL_RULES } from './workspaceTeamTools.js'
@@ -115,7 +115,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       const tools = response.status === 200 && value.ready === true && value.native_tools === true && value.in_process === true
       return { tools, memory: tools && value.memory_isolation === true, extraction: value.memory_extraction === true }
     } catch (error) {
-      if (error instanceof HttpError && ['computer_busy', 'computer_quota', 'runner_offline'].includes(error.code ?? '')) throw error
+      if (error instanceof HttpError && RESOURCE_WAIT_CODES.has(error.code ?? '')) throw error
       return { tools: false, memory: false, extraction: false }
     }
   }
@@ -356,7 +356,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       this.live.delete(key)
       try {
         const current = this.getWork(owner, run.id)
-        if(error&&!submitted&&current.status!=='interrupted'&&error instanceof HttpError&&['computer_busy','computer_quota','runner_offline'].includes(error.code??'')){
+        if(error&&!submitted&&current.status!=='interrupted'&&error instanceof HttpError&&RESOURCE_WAIT_CODES.has(error.code??'')){
           current.status='queued';current.resourceWait=true;current.error='等待执行节点或电脑资源';resultMessage.status='queued';resultMessage.visible=false;resultMessage.error=undefined
         }else if (error) {
           current.status = current.status === 'interrupted' ? 'interrupted' : submitted ? 'uncertain' : 'failed'

+ 2 - 1
src/server/workspaceScheduler.ts

@@ -6,6 +6,7 @@ import { botRelayIntent, relayFingerprint, relayMessageValue, REPEATED_RELAY_NOT
 import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceMessage as Message, WorkspaceRun as Run, WorkspaceInteraction } from '../shared/workspace.js'
 
 export const NO_REPLY = '[[YAOYAO_NO_REPLY_V1]]'
+export const RESOURCE_WAIT_CODES = new Set(['computer_busy', 'computer_quota'])
 export const WORKSPACE_CONCURRENCY_LIMIT = 10
 export const HOST_FALLBACK = '我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的机器人。'
 const terminal = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
@@ -381,7 +382,7 @@ export abstract class WorkspaceScheduler {
     } catch (error) {
       if (this.closing) return
       const current = this.getWork(owner, work.id)
-      if(!current.submitted&&error instanceof HttpError&&['computer_busy','computer_quota','runner_offline'].includes(error.code??'')&&current.status!=='interrupted'){
+      if(!current.submitted&&error instanceof HttpError&&RESOURCE_WAIT_CODES.has(error.code??'')&&current.status!=='interrupted'){
         current.status='queued';current.resourceWait=true;current.error='等待执行节点或电脑资源';this.saveWork(owner,current)
       }else if (!terminal(current.status) && current.status !== 'uncertain') {
         current.status = current.submitted ? 'uncertain' : 'failed'

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

@@ -12,7 +12,9 @@ it('uses the configured idle deadline, resets it after reuse, and never exempts
   f.pool.idleStopMinutes=15
   await f.pool.desktop(resource,'start',()=>{})
   f.advance(5*60000);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
-  const lease=await f.pool.acquire(resource,'next',()=>{});await f.pool.release(lease,true)
+  const lease=await f.pool.acquire(resource,'next',()=>{})
+  expect(vi.mocked(f.provider.ensure)).toHaveBeenCalledTimes(2)
+  await f.pool.release(lease,true)
   f.advance(15*60000-1);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('idle')
   f.advance(1);await f.pool.expire();expect(f.pool.status('owner')[0]?.status).toBe('free')
   f.pool.idleStopMinutes=0;await f.pool.desktop(resource,'start',()=>{})

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

@@ -1599,6 +1599,30 @@ it('queues an unsubmitted turn while computer capacity is unavailable and submit
   expect(requests.filter(request=>request.method==='prompt.submit')).toHaveLength(1)
 })
 
+it('fails an unsubmitted turn immediately when the computer runner is offline',async()=>{
+  const a=store.updateAgent(owner,agent('离线电脑').id,{execution:'computer',computer:'vm'}),c=direct(a.id)
+  nodes.targetForAgent=vi.fn(()=>{throw new HttpError(503,'执行节点未连接,请启动 Runner 后重试','runner_offline')})
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能无声等待'})
+  await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
+  const work=store.list<Work>(owner,'turn').find(work=>work.runId===run.id)!
+  expect(work.resourceWait).toBe(false)
+  expect(work.error).toBe('执行节点未连接,请启动 Runner 后重试')
+  expect(store.messages(owner,c.id).at(-1)?.content).toBe('执行失败:执行节点未连接,请启动 Runner 后重试')
+})
+
+it('can stop an unsubmitted turn that is waiting for computer capacity',async()=>{
+  const a=store.updateAgent(owner,agent('可停止等候').id,{execution:'computer',computer:'vm'}),c=direct(a.id)
+  nodes.targetForAgent=vi.fn(()=>{throw new HttpError(429,'资源不足','computer_quota')})
+  const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'等待中停止'})
+  const work=await vi.waitFor(()=>{
+    const current=store.list<Work>(owner,'turn').find(work=>work.runId===run.id)
+    expect(current?.resourceWait).toBe(true)
+    return current!
+  })
+  await runtime.stop(owner,run.id)
+  await vi.waitFor(()=>expect(store.require<Work>(owner,'turn',work.id).status).toBe('interrupted'))
+})
+
 it('forwards a generated artifact to a new task without changing its original provenance',async()=>{
   const a=agent('产物接收者'),c=direct(a.id)
   const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'first'})