Sfoglia il codice sorgente

合并 Bot 跨会话并发与十路执行上限到主分支

Samien 1 mese fa
parent
commit
8dec8751d4

+ 27 - 0
docs/verification/2026-09-13-session-concurrency/README.md

@@ -0,0 +1,27 @@
+# Bot 独立会话并发验收
+
+基线:主分支 `b880460`。开发分支:`codex/session-continuity-10`。
+
+## 行为
+
+- 同一机器人可以在不同聊天或话题中同时回复。顺序约束按既有会话绑定(聊天 ID、话题 ID、机器人 ID)判断。
+- 全局最多执行 10 个 Bot 轮次,超出的已接收工作保留在队列中;运行中及状态待确认的工作继续占用额度。
+- 同一群聊最多接收 10 个未完成话题,第 11 个新话题明确返回并发限制,不中断或丢弃已有工作。用户入口和团队工具使用相同限制。
+- 同一话题内原有的成员协作限制(最多 3 个并行轮次)和主持人顺序继续生效。
+- 停止、交互答复、历史恢复仍通过原绑定定位会话。虚拟机独占租约、浏览器和物理桌面的操作队列继续控制共享资源;虚拟机数量配置独立于 Bot 并发数。
+
+## 验证
+
+运行 `bash docs/verification/2026-09-13-session-concurrency/verify.sh`。
+
+使用隔离临时数据库和本机 WebSocket 夹具,不调用真实模型或线上会话。覆盖:
+
+1. 同一机器人 10 个话题同时进入执行,各自获得独立 session;第 11 个话题被拒绝且没有发送任何中断。
+2. 同一机器人的不同话题交错完成,消息、上下文、存储会话和未读状态各自归属。
+3. 同一 session 的追问排队并复用原会话;停止其中一个话题不会停止另一话题。
+4. 同名审批请求隔离,答复只发给对应 session。
+5. 两个并发 session 重启后各自核对历史,不重新提交用户请求。
+6. 跨聊天达到全局 10 路后保留后续工作;完成后继续执行,单话题最多 3 路。
+7. 共享浏览器操作串行执行,取消一个排队 session 不取消前后两个 session;虚拟机租约继续拒绝第二个占用者。
+
+结果见 `results.txt`。本次没有修改部署、安装包或正式版本号。

+ 31 - 0
docs/verification/2026-09-13-session-concurrency/results.txt

@@ -0,0 +1,31 @@
+
+> yaoyao@0.4.17-bot-latency.1 test
+> vitest run tests/server/workspace.test.ts tests/server/workspaceSync.test.ts tests/server/workspaceTeamTools.test.ts tests/server/taskCoordinator.test.ts tests/server/runner.test.ts tests/server/computerPool.test.ts tests/server/desktopEnvironments.test.ts
+
+
+ RUN  v3.2.7 /Users/samien/git/session-continuity-10/yaoyao
+
+ ✓ tests/server/workspaceSync.test.ts (4 tests) 91ms
+ ✓ tests/server/computerPool.test.ts (9 tests) 166ms
+ ✓ tests/server/workspaceTeamTools.test.ts (25 tests) 377ms
+ ✓ tests/server/desktopEnvironments.test.ts (10 tests) 483ms
+ ✓ tests/server/taskCoordinator.test.ts (15 tests) 1312ms
+ ✓ tests/server/runner.test.ts (17 tests) 1946ms
+ ✓ tests/server/workspace.test.ts (78 tests) 13761ms
+   ✓ automatically reconciles lost completion without submitting the prompt again  538ms
+   ✓ keeps an unconfirmed turn separate from a later reply and retries history without resubmitting  1571ms
+   ✓ automatic relay termination > starts fresh for identical new requests in the same task and a different task  439ms
+   ✓ retries a rolled-back cascade plan without losing or duplicating delegation  592ms
+   ✓ pages backwards to find a long-running turn marker and never mistakes a tool call for a final answer  752ms
+   ✓ queues an unsubmitted turn while computer capacity is unavailable and submits it once after recovery  1052ms
+
+ Test Files  7 passed (7)
+      Tests  158 passed (158)
+   Start at  23:42:16
+   Duration  14.28s (transform 458ms, setup 135ms, collect 1.76s, tests 18.14s, environment 1ms, prepare 337ms)
+
+
+> yaoyao@0.4.17-bot-latency.1 release:verify
+> node scripts/verify-release.mjs
+
+发布版本校验通过:Web 0.4.17-bot-latency.1

+ 10 - 0
docs/verification/2026-09-13-session-concurrency/verify.sh

@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
+export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--no-experimental-webstorage"
+npm test -- tests/server/workspace.test.ts tests/server/workspaceSync.test.ts \
+  tests/server/workspaceTeamTools.test.ts tests/server/taskCoordinator.test.ts \
+  tests/server/runner.test.ts tests/server/computerPool.test.ts \
+  tests/server/desktopEnvironments.test.ts
+npx tsc --noEmit -p tsconfig.server.json
+npm run release:verify

+ 3 - 3
src/server/workspaceRuntime.ts

@@ -17,7 +17,7 @@ import type {
   WorkspaceInteraction,
 } from '../shared/workspace.js'
 
-import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK } from './workspaceScheduler.js'
+import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK, WORKSPACE_CONCURRENCY_LIMIT } from './workspaceScheduler.js'
 import type { WorkspacePlugins, OpenBotPlugins } from './botPlugins/workspacePlugins.js'
 import { mentionedAgents } from './workspaceMentions.js'
 import { WorkspaceTeamTools, TEAM_TOOL_RULES } from './workspaceTeamTools.js'
@@ -108,8 +108,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         conversationTaskId = conversationTask?.id
       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) >= 4)
-          throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
+        if (!alreadyActive && this.store.activeTaskCount(owner, conversationId) >= WORKSPACE_CONCURRENCY_LIMIT)
+          throw new HttpError(409, `最多同时运行 ${WORKSPACE_CONCURRENCY_LIMIT} 个任务`, 'workspace_task_concurrency_limit')
       }
       if (body.mentionIds.some((a) => !this.store.taskMemberIds(owner,c,conversationTaskId).includes(a)))
         throw new HttpError(400, '只能 @ 群内成员', 'invalid_mentions')

+ 6 - 5
src/server/workspaceScheduler.ts

@@ -5,6 +5,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 WORKSPACE_CONCURRENCY_LIMIT = 10
 export const HOST_FALLBACK = '我还不能确定你希望我处理什么,请补充具体目标、范围,或明确需要我协调的机器人。'
 const terminal = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
 export interface Work {
@@ -299,7 +300,7 @@ export abstract class WorkspaceScheduler {
           }
           continue
         }
-        if (this.executing.size >= 4 || !['queued', 'uncertain'].includes(work.status) || this.executing.has(work.id)) continue
+        if (this.executing.size >= WORKSPACE_CONCURRENCY_LIMIT || !['queued', 'uncertain'].includes(work.status) || this.executing.has(work.id)) continue
         if (!this.userActive(owner)) { this.retrySoon(); continue }
         const c = this.store.require<Conversation>(owner, 'conversation', work.conversationId), root = this.store.require<Run>(owner, 'run', work.runId)
         if (work.status === 'queued' && !root.assignmentId && c.mode === 'host' && work.depth === 0 && work.batchId === root.id && work.requiredReply && !work.currentMessageId) {
@@ -309,12 +310,12 @@ export abstract class WorkspaceScheduler {
           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)
-        // A persistent Agent has one mutable execution lane across every chat
-        // and task. Parallel work belongs to different Agents/resources.
-        if (occupied.some(t => t.agentId === work.agentId)) continue
+        // 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.
         if (occupied.some(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId && t.agentId === work.agentId)) continue
         const inTask = occupied.filter(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId)
-        if (work.status === 'queued' && (occupied.length >= 4 || inTask.length >= 3)) continue
+        if (work.status === 'queued' && (occupied.length >= WORKSPACE_CONCURRENCY_LIMIT || inTask.length >= 3)) continue
         if ((this.recoverAfter.get(work.id) ?? 0) > Date.now()) { this.retrySoon(); continue }
         if (c.mode === 'host' || c.kind === 'direct') {
           if (inTask.some(t => c.kind === 'direct'

+ 3 - 3
src/server/workspaceTeamTools.ts

@@ -4,7 +4,7 @@ import { HttpError } from './errors.js'
 import { agentInput, parse, type WorkspaceStore } from './workspaceStore.js'
 import type { WorkspaceNodes } from './workspaceGateway.js'
 import type { WorkspaceRuntime } from './workspaceRuntime.js'
-import type { Work } from './workspaceScheduler.js'
+import { WORKSPACE_CONCURRENCY_LIMIT, type Work } from './workspaceScheduler.js'
 import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceRun as Run, WorkspaceInteraction } from '../shared/workspace.js'
 import { requireTeamToolBridge } from './workspaceToolLease.js'
 import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel, finishGoalInput, resumeGoalInput, goalCriteriaInput } from './taskCoordinator.js'
@@ -203,8 +203,8 @@ export class WorkspaceTeamTools {
           this.store.saveRun(owner, sourceRun)
           return { teamId: team.id, teamName: team.name, task: { ...task, goal }, run: sourceRun, message: '当前话题已作为交付目标,请按需直接完成或分工,不要重复启动。' }
         }
-        if (this.store.activeTaskCount(owner, team.id) >= 4)
-          throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
+        if (this.store.activeTaskCount(owner, team.id) >= WORKSPACE_CONCURRENCY_LIMIT)
+          throw new HttpError(409, `最多同时运行 ${WORKSPACE_CONCURRENCY_LIMIT} 个任务`, 'workspace_task_concurrency_limit')
         const emptyTask = this.store.tasks(owner, team.id).find(t => t.messageCount === 0 && !t.activeRunId && t.titleSource === 'automatic')
         const task = emptyTask
           ? this.store.updateTask(owner, team.id, emptyTask.id, { title: body.title })

+ 22 - 0
tests/server/desktopEnvironments.test.ts

@@ -32,6 +32,28 @@ it('adds an authorized native desktop to hybrid tools while leaving the default
 function drive(handle:(c:any)=>unknown=c=>c.operation==='view'?{data:Buffer.from('fixture frame').toString('base64'),width:1280,height:800}:{ok:true}){let results:any[]=[];service.exchange({host,results});poller=setInterval(()=>{const value=service.exchange({host,results});results=value.commands.map(c=>({id:c.id,value:handle(c)}))},5)}
 async function take(){return (await request(app()).post(base()+'/computer/take').send({requestId:randomUUID()}).expect(200)).body}
 async function frame(){return (await request(app()).get(base()+'/computer/frame').expect(200)).body}
+it('queues browser operations from concurrent sessions and cancels only the waiting session',async()=>{
+ service.exchange({host,results:[]})
+ const first=new AbortController(),cancelled=new AbortController(),third=new AbortController()
+ const navigate=(signal:AbortSignal,path:string)=>service.call('owner',agent.id,'desktop_browser',{kind:'navigate',url:`https://example.test/${path}`},signal,()=>{},'browser',service.epoch)
+ const one=navigate(first.signal,'first'),two=navigate(cancelled.signal,'cancelled').catch(error=>error),three=navigate(third.signal,'third')
+ let commands:any[]=[]
+ await vi.waitFor(()=>{commands=service.exchange({host,results:[]}).commands;expect(commands).toHaveLength(1)})
+ const initial=commands[0]
+ expect(initial.action.url).toBe('https://example.test/first')
+ expect(service.exchange({host,results:[]}).commands).toEqual([])
+ cancelled.abort()
+ service.exchange({host,results:[{id:initial.id,value:{ok:true}}]})
+ await one
+ expect(await two).toMatchObject({name:'AbortError'})
+ await vi.waitFor(()=>{commands=service.exchange({host,results:[]}).commands;expect(commands).toHaveLength(1)})
+ expect(commands[0].action.url).toBe('https://example.test/third')
+ expect(commands[0].resource).toBe(initial.resource)
+ service.exchange({host,results:[{id:commands[0].id,value:{ok:true}}]})
+ await expect(three).resolves.toEqual({ok:true})
+ expect(first.signal.aborted).toBe(false)
+ expect(third.signal.aborted).toBe(false)
+})
 it('keeps capability private and refuses origins before parsing native commands',async()=>{const instance=acquireServiceInstance(home,'0.4.3'),a=new Koa().use(instance.middleware(async()=>{},()=>true,ctx=>service.bridge(ctx)));try{await request(a.callback()).post('/desktop/environment').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).set('Origin','http://evil.test').send({host,results:[]}).expect(403);await request(a.callback()).post('/desktop/environment').set('x-yaoyao-desktop-token',instance.record.token).send({host,results:[]}).expect(200);expect(service.online).toBe(true)}finally{instance.release()}})
 it('auto selects only an approved and ready local host; observing never opens a browser',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'auto'});expect(service.selected('owner',agent)).toBeUndefined();expect(service.exchange({host:{...host,approved:[]},results:[]}).commands).toEqual([]);expect(service.selected('owner',agent)).toBeUndefined();service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBe('local');host.screen=false;service.exchange({host,results:[]});expect(service.selected('owner',agent)).toBeUndefined();await request(app()).get(base()+'/desktop-environment').set('x-user','other').expect(404)})
 it('shares the physical desktop across owners and robots but scopes manual tokens to their owner and robot',async()=>{agent=store.updateAgent('owner',agent.id,{computer:'local'});const other=store.createAgent('other',{name:'Other',nodeId:'local',profile:'default',computer:'local'});host.approved.push(ownerKey('other'));drive();const ticket=await take();await request(app()).post('/api/app/agents/'+other.id+'/computer/take').set('x-user','other').send({requestId:randomUUID()}).expect(409);await request(app()).post('/api/app/agents/'+other.id+'/computer/renew').set('x-user','other').send(ticket).expect(410);const state=(await request(app()).get('/api/app/agents/'+other.id+'/computer').set('x-user','other')).body;expect(state.controlId).toBeUndefined();await request(app()).post(base()+'/computer/giveback').send(ticket).expect(200)})

+ 78 - 26
tests/server/workspace.test.ts

@@ -323,7 +323,7 @@ describe('Web-owned workspace', () => {
     }
   })
 
-  it('serializes one Agent across group tasks while keeping messages, sessions and context isolated', async () => {
+  it('runs one Agent across group tasks concurrently while keeping messages, sessions and context isolated', async () => {
     const administrator = store.createAgent(owner, { name: '多任务远程管理员', profile: 'remote-profile', nodeId: 'remote-node' }),
       member = agent('多任务成员'),
       conversation = store.createGroup(owner, {
@@ -346,13 +346,14 @@ describe('Web-owned workspace', () => {
     }
     const firstRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '第一项工作' }),
       secondRun = runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '第二项工作' })
-    await vi.waitFor(()=>expect(pending).toHaveLength(1))
-    expect(store.require<WorkspaceRun>(owner,'run',secondRun.id).status).toBe('queued')
-    complete(pending[0]!)
-    await finished(firstRun.id)
     await vi.waitFor(()=>expect(pending).toHaveLength(2))
-    complete(pending[1]!)
+    expect(store.require<WorkspaceRun>(owner,'run',firstRun.id).status).toBe('running')
+    expect(store.require<WorkspaceRun>(owner,'run',secondRun.id).status).toBe('running')
+    complete(pending.find(item => item.params.text.includes('第二项工作'))!)
     await finished(secondRun.id)
+    expect(store.require<WorkspaceRun>(owner,'run',firstRun.id).status).toBe('running')
+    complete(pending.find(item => item.params.text.includes('第一项工作'))!)
+    await finished(firstRun.id)
     const firstMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, firstTask.id),
       secondMessages = store.messages(owner, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, secondTask.id)
     expect(firstMessages.map(message => message.content)).toEqual(['第一项工作', '第一项结果'])
@@ -380,7 +381,7 @@ describe('Web-owned workspace', () => {
       .toEqual(['第二项工作', '第二项结果'])
   })
 
-  it('limits a group to four active conversation tasks while retaining the global scheduler limit', async () => {
+  it('admits ten active conversation tasks for one Agent and rejects the eleventh without stopping them', async () => {
     const administrator = agent('并发任务管理员'),
       member = agent('并发任务成员'),
       conversation = store.createGroup(owner, {
@@ -388,14 +389,17 @@ describe('Web-owned workspace', () => {
         memberIds: [administrator.id, member.id],
         administratorId: administrator.id,
       }),
-      tasks = [defaultTask(conversation.id), ...Array.from({ length: 4 }, (_, index) => store.createTask(owner, conversation.id, { title: `任务 ${index + 2}` }))]
+      tasks = [defaultTask(conversation.id), ...Array.from({ length: 10 }, (_, index) => store.createTask(owner, conversation.id, { title: `任务 ${index + 2}` }))]
     reply = () => {}
-    for (const [index, task] of tasks.slice(0, 4).entries())
+    for (const [index, task] of tasks.slice(0, 10).entries())
       runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: task.id, content: `执行 ${index + 1}` })
-    expect(store.activeTaskCount(owner, conversation.id)).toBe(4)
-    expect(() => runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: tasks[4]!.id, content: '第五项' }))
-      .toThrow('最多同时运行 4 个任务')
-    await Promise.all(tasks.slice(0, 4).map(task => runtime.stopTask(owner, conversation.id, task.id)))
+    expect(store.activeTaskCount(owner, conversation.id)).toBe(10)
+    await vi.waitFor(() => expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(10))
+    expect(new Set(requests.filter(r => r.method === 'prompt.submit').map(r => r.params.session_id)).size).toBe(10)
+    expect(() => runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: tasks[10]!.id, content: '第十一项' }))
+      .toThrow('最多同时运行 10 个任务')
+    expect(requests.filter(r => r.method === 'session.interrupt')).toHaveLength(0)
+    await Promise.all(tasks.slice(0, 10).map(task => runtime.stopTask(owner, conversation.id, task.id)))
   })
 
   it('keeps same-named upstream interactions scoped to their conversation task', async () => {
@@ -422,11 +426,12 @@ describe('Web-owned workspace', () => {
     // insertion order. Establish the active task before testing its isolation.
     await vi.waitFor(() => expect(pending).toHaveLength(1))
     const secondRun=runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: secondTask.id, content: '任务乙' })
-    await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction').filter(interaction => !interaction.resolved)).toHaveLength(1))
+    await vi.waitFor(() => expect(store.list<WorkspaceInteraction>(owner, 'interaction').filter(interaction => !interaction.resolved)).toHaveLength(2))
     const first=store.list<WorkspaceInteraction>(owner,'interaction').find(i=>i.conversationTaskId===firstTask.id)!
     expect(store.require<any>(owner, 'conversation-task', firstTask.id).activeRunStatus).toBe('waiting')
-    expect(store.require<WorkspaceRun>(owner,'run',secondRun.id).status).toBe('queued')
+    expect(store.require<WorkspaceRun>(owner,'run',secondRun.id).status).toBe('waiting')
     await runtime.respond(owner, first.id, 'once')
+    expect(requests.filter(r => r.method === 'approval.respond').map(r => r.params.session_id)).toEqual([pending[0]!.params.session_id])
     pending[0]!.socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:pending[0]!.params.session_id,payload:{text:'任务甲完成',status:'complete'}}}))
     await finished(firstRun.id)
     await vi.waitFor(()=>expect(store.list<WorkspaceInteraction>(owner,'interaction')).toHaveLength(2))
@@ -1196,22 +1201,69 @@ it('bounds long context while preserving the task beginning and ending', async (
   expect(prompt).toContain('保留首尾片段');expect(prompt.length).toBeLessThan(32_000)
 })
 
-it('enforces three tasks per group and four globally while preserving queued work', async () => {
-  const agents=['并发甲','并发乙','并发丙','并发丁','其他群'].map(n=>agent(n))
+it('serializes a session and stops only that task while the same Agent keeps replying elsewhere', async () => {
+  const a=agent('同一机器人'), b=agent('旁观成员')
+  const c=store.createGroup(owner,{name:'会话隔离',memberIds:[a.id,b.id],administratorId:a.id})
+  const first=defaultTask(c.id), second=store.createTask(owner,c.id,{})
+  const pending:Array<{socket:WebSocket;p:Record<string,any>}>=[]
+  reply=(socket,p)=>pending.push({socket,p})
+  const one=runtime.send(owner,c.id,{requestId:randomUUID(),taskId:first.id,content:'第一话题开始'})
+  await vi.waitFor(()=>expect(pending).toHaveLength(1))
+  const queued=runtime.send(owner,c.id,{requestId:randomUUID(),taskId:first.id,content:'第一话题追问'})
+  const other=runtime.send(owner,c.id,{requestId:randomUUID(),taskId:second.id,content:'第二话题独立执行'})
+  await vi.waitFor(()=>expect(pending).toHaveLength(2))
+  expect(store.require<WorkspaceRun>(owner,'run',queued.id).status).toBe('queued')
+  completeReply(pending[0]!.socket,pending[0]!.p,'第一话题首轮结果')
+  await finished(one.id)
+  await vi.waitFor(()=>expect(pending).toHaveLength(3))
+  const firstBinding=store.get<any>(owner,'binding',`${c.id}:${first.id}:${a.id}`)!
+  expect(requests.filter(r=>r.method==='session.resume').map(r=>r.params.session_id)).toEqual([firstBinding.storedId])
+  expect(pending[2]!.p.text).not.toContain('第二话题独立执行')
+  await runtime.stopTask(owner,c.id,first.id)
+  expect(requests.filter(r=>r.method==='session.interrupt').map(r=>r.params.session_id)).toEqual([pending[2]!.p.session_id])
+  expect(store.require<WorkspaceRun>(owner,'run',other.id).status).toBe('running')
+  expect(store.require<any>(owner,'conversation-task',second.id).activeRunStatus).toBe('running')
+  completeReply(pending[1]!.socket,pending[1]!.p,'第二话题独立完成')
+  await finished(other.id)
+  expect(store.messages(owner,c.id,Number.MAX_SAFE_INTEGER,100,false,second.id).map(m=>m.content)).toEqual(['第二话题独立执行','第二话题独立完成'])
+  expect(store.require<WorkspaceRun>(owner,'run',queued.id).status).toBe('interrupted')
+})
+
+it('recovers simultaneous sessions for one Agent after restart without replaying either prompt', async () => {
+  const a=agent('恢复机器人'),b=agent('恢复旁观者')
+  const c=store.createGroup(owner,{name:'并发恢复',memberIds:[a.id,b.id],administratorId:a.id})
+  const tasks=[defaultTask(c.id),store.createTask(owner,c.id,{})]
+  reply=(_socket,p)=>recoveryByStored.set(storedByRuntime.get(p.session_id)!,[
+    {role:'user',content:p.text},{role:'assistant',content:p.text.includes('恢复甲')?'甲的最终结果':'乙的最终结果'},
+  ])
+  const runs=tasks.map((task,i)=>runtime.send(owner,c.id,{requestId:randomUUID(),taskId:task.id,content:i===0?'恢复甲':'恢复乙'}))
+  await vi.waitFor(()=>expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(2))
+  runtime.close();runtime=new WorkspaceRuntime(store,nodes,uploads);runtime.start()
+  await Promise.all(runs.map(r=>finished(r.id)))
+  expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(2)
+  expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(0)
+  expect(tasks.map(task=>store.messages(owner,c.id,Number.MAX_SAFE_INTEGER,100,false,task.id).at(-1)?.content)).toEqual(['甲的最终结果','乙的最终结果'])
+})
+
+it('enforces three turns per topic and ten globally while preserving queued work', async () => {
+  const agents=Array.from({length:12},(_,i)=>agent(`并发成员${i}`))
   const group=store.createGroup(owner,{name:'并发限制',memberIds:agents.slice(0,4).map(a=>a.id),administratorId:agents[0]!.id,mode:'free',autoReplyIds:agents.slice(0,4).map(a=>a.id),maxReplyRounds:1})
   const pending=new Map<string,{socket:WebSocket;p:Record<string,any>}>()
   reply=(socket,p)=>pending.set(speaker(p)!,{socket,p})
   const one=runtime.send(owner,group.id,{requestId:randomUUID(),content:'并发测试'})
-  const two=runtime.send(owner,direct(agents[4]!.id).id,{requestId:randomUUID(),content:'另一群'})
-  await vi.waitFor(()=>expect(pending.size).toBe(4))
+  await vi.waitFor(()=>expect(pending.size).toBe(3))
+  const others=agents.slice(4).map(a=>runtime.send(owner,direct(a.id).id,{requestId:randomUUID(),content:'另一群'}))
+  await vi.waitFor(()=>expect(pending.size).toBe(10))
   expect(Object.keys(store.require<WorkspaceConversation>(owner,'conversation',group.id).activeAgentStates!)).toHaveLength(3)
-  expect(store.list<any>(owner,'turn').filter(t=>t.status==='running')).toHaveLength(4)
-  const released=[...pending.values()].find(v=>speaker(v.p)!==agents[4]!.name)!
-  completeReply(released.socket,released.p,'完成')
-  await vi.waitFor(()=>expect(pending.size).toBe(5))
-  expect(store.list<any>(owner,'turn').filter(t=>t.status==='running').length).toBeLessThanOrEqual(4)
-  for(const v of pending.values()) if(v!==released) completeReply(v.socket,v.p,'完成')
-  await finished(one.id);await finished(two.id)
+  expect(store.list<any>(owner,'turn').filter(t=>t.status==='running')).toHaveLength(10)
+  expect(store.list<any>(owner,'turn').filter(t=>t.status==='queued')).toHaveLength(2)
+  expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(0)
+  const released=[...pending.values()]
+  for(const v of released) completeReply(v.socket,v.p,'完成')
+  await vi.waitFor(()=>expect(pending.size).toBe(12))
+  expect(store.list<any>(owner,'turn').filter(t=>t.status==='running').length).toBeLessThanOrEqual(10)
+  for(const v of pending.values()) if(!released.includes(v)) completeReply(v.socket,v.p,'完成')
+  await finished(one.id);await Promise.all(others.map(r=>finished(r.id)))
 })
 
 it('supports unlimited rounds beyond the finite limit and still permits stopping', async () => {