Explorar o código

修复 Bot 群聊重复派工与自动收尾

Samien hai 1 mes
pai
achega
44c3d25184

+ 42 - 0
docs/verification/2026-09-10-bot-relay/README.md

@@ -0,0 +1,42 @@
+# Bot 群聊自动收尾与防循环验收
+
+日期:2026-09-10。只修改源码与隔离测试,没有调整远端配置、历史消息或运行服务。
+
+## 行为
+
+- 用户的 @ 解析保持不变;机器人回复的明确确认、感谢、等待用户指令不再派工。含新动作或含糊内容继续按原方式路由。自由协作不会把已过滤的提及重新变成自动广播。
+- 两种群聊模式在同一 run 内按发言成员与目标成员检查重复交接。输入消息/复核批次结果、回复、工具结果、附件、用户交互答复参与判断;第二次相同交接只阻止对应分支,不取消其他工作。
+- 只规范化普通正文的格式空白。代码、附件身份、路径、工具参数和结果保留;工具事件顶层的编号和计时元数据不算新进展。新的人工答复独立于机器事件去重。
+- 指纹、抑制原因和每轮最多一次的系统提示与调度结果原子保存。旧 turn 没有指纹时,依据已保存的回复和实际派工记录推导。管理员批次复核、有限轮数和手动停止保持原行为。
+- 不新增公开接口、客户端状态或数据库表。不改变结构化目标的验收状态。不限轮数仍允许持续推进;规则不进行模型语义分类或模糊相似度判定。
+
+## 回归
+
+以下命令成功,7 个文件、166 项通过:
+
+```sh
+npx vitest run tests/server/workspaceRelay.test.ts tests/server/workspace.test.ts tests/server/workspaceTeamTools.test.ts tests/server/taskCoordinator.test.ts tests/server/workspaceRoutes.test.ts tests/server/workspaceRemoteAgents.test.ts tests/server/workspacePairedNodes.test.ts
+```
+
+覆盖原始确认循环、两种模式的两/三成员循环、重复完成事件、分支与目标隔离、新消息与任务、新结果/工具/附件/人工答复、重启与旧记录恢复,以及既有审批、手动停止、事务回滚、远程成员和账号授权场景。
+
+原审批隔离测试存在同毫秒跨任务排序假设,已改为先确认第一条任务实际开始,再提交第二条并验证排队和审批隔离。
+
+`npm run typecheck` 和 `npm run build` 成功。构建仍有既有的前端 chunk 超过 500 kB 提示。
+
+## 编译后隔离验收
+
+```sh
+npm run build
+node docs/verification/2026-09-10-bot-relay/verify.mjs
+```
+
+脚本加载 `dist-server` 的实际编译代码,使用临时 SQLite 目录和回环地址的假 Hermes WebSocket 服务;不连接真实模型或现有服务,退出时清理自己的测试目录。
+
+| 场景(均不限轮数) | 成员调用 | 重复提示 | 最终状态 | 待执行任务 |
+| --- | ---: | ---: | --- | ---: |
+| 管理员派工、审核、最终确认 | 3 | 0 | complete | 0 |
+| 管理员模式持续返回相同派工及结果 | 5 | 1 | complete | 0 |
+| 自由模式持续互相派工 | 4 | 1 | complete | 0 |
+
+各场景结束后继续观察 250 ms,没有新增调用,聊天的活动运行标记已清除。结构化输出见 [result.json](result.json)。此验收不代表生产服务器已部署。

+ 33 - 0
docs/verification/2026-09-10-bot-relay/result.json

@@ -0,0 +1,33 @@
+{
+  "source": "dist-server",
+  "network": "loopback fixture only",
+  "results": [
+    {
+      "mode": "host",
+      "closingReceipt": true,
+      "calls": 3,
+      "status": "complete",
+      "pending": 0,
+      "notices": 0,
+      "stableAfterMs": 250
+    },
+    {
+      "mode": "host",
+      "closingReceipt": false,
+      "calls": 5,
+      "status": "complete",
+      "pending": 0,
+      "notices": 1,
+      "stableAfterMs": 250
+    },
+    {
+      "mode": "free",
+      "closingReceipt": false,
+      "calls": 4,
+      "status": "complete",
+      "pending": 0,
+      "notices": 1,
+      "stableAfterMs": 250
+    }
+  ]
+}

+ 75 - 0
docs/verification/2026-09-10-bot-relay/verify.mjs

@@ -0,0 +1,75 @@
+// Run after npm run build. Uses only a temporary SQLite home and loopback fake
+// Hermes; no existing service, account, model, or group is contacted.
+import assert from 'node:assert/strict'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { WebSocketServer } from 'ws'
+import { WorkspaceRuntime } from '../../../dist-server/server/workspaceRuntime.js'
+import { WorkspaceStore } from '../../../dist-server/server/workspaceStore.js'
+import { UploadStore } from '../../../dist-server/server/uploads.js'
+
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
+const receipt = '收到 @审核 终审维持通过。\n交付状态(不变):入草稿箱 Media ID `fixture-draft`,发布目录 `/fixture/final/`。\n等用户群发指令。'
+async function verify(mode, closingReceipt, expectedCalls) {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-relay-acceptance-'))
+  const store = new WorkspaceStore(home), uploads = new UploadStore(home)
+  const server = new WebSocketServer({ host: '127.0.0.1', port: 0 })
+  let runtime, calls = 0
+  try {
+    await new Promise(resolve => server.once('listening', resolve))
+    const target = {
+      url: new URL(`http://127.0.0.1:${server.address().port}`), client: {},
+      session: {
+        webSocketCredential: async () => ({ name: 'ticket', value: 'fixture' }),
+        request: async () => ({ status: 200, body: Buffer.from('{}') }),
+      },
+    }
+    server.on('connection', socket => {
+      socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready' } }))
+      socket.on('message', raw => {
+        const frame = JSON.parse(String(raw)), respond = result => socket.send(JSON.stringify({ id: frame.id, result }))
+        if (frame.method === 'session.create' || frame.method === 'session.resume') {
+          respond({ session_id: randomUUID(), stored_session_id: frame.params.session_id ?? randomUUID(), running: false, info: { profile_name: 'default' } })
+        } else if (frame.method === 'prompt.submit') {
+          calls++; respond({ status: 'streaming' })
+          const lead = frame.params.text.startsWith('你是 竹儿。')
+          const text = calls > 20 ? '完成' : lead ? closingReceipt && calls > 1 ? receipt : '@审核 请再次检查文案'
+            : mode === 'host' ? '审核结果相同' : '@竹儿 请再次检查文案'
+          socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: frame.params.session_id, payload: { text, status: 'complete' } } }))
+        } else respond({ ok: true })
+      })
+    })
+    runtime = new WorkspaceRuntime(store, { requireSource() {}, target: () => target }, uploads)
+    const owner = 'isolated-fixture'
+    const a = store.createAgent(owner, { name: '竹儿', profile: 'default', instructions: '' })
+    const b = store.createAgent(owner, { name: '审核', profile: 'default', instructions: '' })
+    const group = store.createGroup(owner, { name: '隔离验收', memberIds: [a.id, b.id], administratorId: a.id, mode, maxReplyRounds: -1 })
+    const run = runtime.send(owner, group.id, { requestId: randomUUID(), content: '@竹儿 开始' })
+    const deadline = Date.now() + 10_000
+    while (store.require(owner, 'run', run.id).status !== 'complete' && Date.now() < deadline) await sleep(10)
+    assert.equal(store.require(owner, 'run', run.id).status, 'complete')
+    assert.equal(calls, expectedCalls)
+    await sleep(250)
+    assert.equal(calls, expectedCalls, 'no further calls after settling')
+    const pending = store.list(owner, 'turn').filter(w => !w.planned || !['complete', 'failed', 'interrupted'].includes(w.status))
+    assert.equal(pending.length, 0)
+    assert.equal(store.require(owner, 'conversation', group.id).activeRunId, undefined)
+    const notices = store.messages(owner, group.id).filter(m => m.role === 'system' && m.content.includes('检测到重复协作'))
+    assert.equal(notices.length, closingReceipt ? 0 : 1)
+    return { mode, closingReceipt, calls, status: 'complete', pending: pending.length, notices: notices.length, stableAfterMs: 250 }
+  } finally {
+    runtime?.close()
+    for (const socket of server.clients) socket.terminate()
+    await new Promise(resolve => server.close(resolve))
+    await sleep(10)
+    uploads.close(); store.close()
+    rmSync(home, { recursive: true, force: true })
+  }
+}
+const results = []
+for (const [mode, closingReceipt, calls] of [['host', true, 3], ['host', false, 5], ['free', false, 4]]) {
+  results.push(await verify(mode, closingReceipt, calls))
+}
+console.log(JSON.stringify({ source: 'dist-server', network: 'loopback fixture only', results }, null, 2))

+ 7 - 3
src/server/workspaceMentions.ts

@@ -1,14 +1,18 @@
 import type { WorkspaceAgent as Agent } from '../shared/workspace.js'
 
-export function mentionedAgents(text: string, agents: Array<Pick<Agent, 'id' | 'name'>>): string[] {
-  if (!text.includes('@')) return []
-  const plain = text
+export function mentionableText(text: string): string {
+  return text
     .replace(/<quoted_message\b[^>]*>[\s\S]*?<\/quoted_message>/gi, '')
     .replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '')
     .replace(/`[^`]*`/g, '')
     .replace(/https?:\/\/\S+/g, '')
     .replace(/[\p{L}\p{N}._%+-]{1,64}@[\p{L}\p{N}.-]{1,255}\.[\p{L}]{2,63}/gu, '')
     .split('\n').filter(line => !line.trimStart().startsWith('>')).join('\n')
+}
+
+export function mentionedAgents(text: string, agents: Array<Pick<Agent, 'id' | 'name'>>): string[] {
+  if (!text.includes('@')) return []
+  const plain = mentionableText(text)
   const names = [...agents].sort((a, b) => b.name.length - a.name.length)
   const alternatives = [...names.map(a => a.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 'all', '所有人']
   const pattern = new RegExp(`(?<![A-Za-z0-9_.%+/@-])@(${alternatives.join('|')})(?![A-Za-z0-9_])`, 'giu')

+ 88 - 0
src/server/workspaceRelay.ts

@@ -0,0 +1,88 @@
+import { createHash } from 'node:crypto'
+import type { WorkspaceAgent, WorkspaceMessage } from '../shared/workspace.js'
+import { mentionableText, mentionedAgents } from './workspaceMentions.js'
+
+type Member = Pick<WorkspaceAgent, 'id' | 'name'>
+export const REPEATED_RELAY_NOTICE = '检测到重复协作,已停止重复分支;其他已安排工作继续执行。'
+
+// Deliberately anchored: a receipt followed by an unfamiliar clause or a new
+// instruction must retain the old routing behavior, not guess that work is done.
+function acknowledgement(text: string, allowReceipt = true): boolean {
+  if (/^(?:(?:终审|审核|复核|验收|任务|工作|本轮)(?:已|已经)?\s*)?(?:已|已经|仍|维持|继续维持)?(?:通过|完成|结束|确认|保持不变|不变)(?:了|啦)?$/u.test(text)) return true
+  if (/^(?:无|没有|暂无)(?:新事实|新任务|新派工|新工作|新进展)$/u.test(text)) return true
+  if (/^(?:本轮)?(?:无需|不用|不要|不必|不再|不)(?:再|继续|重复)?(?:处理|回复|核验|确认|派工|协作|跟进)$/u.test(text)) return true
+  if (/^(?:等|等待|等候|待)(?:(?:用户|人类|你|您|[\p{L}\p{N}]{1,12}(?:哥|姐|老师|先生|女士))(?:的)?)?(?:新|进一步|下一步|群发|发布)?(?:指令|指示)(?:即可)?$/u.test(text)) return true
+  if (/^(?:等待|等候)(?:用户|人类|你|您)(?:的)?(?:确认|回复)$/u.test(text)) return true
+  if (/^(?:acknowledged|received|noted|thanks|thank you|approved|done|completed|no new (?:tasks?|work)|waiting for (?:the )?(?:user|your) (?:instructions?|input|confirmation))$/iu.test(text)) return true
+  const receipt = allowReceipt && /^(?:已收到|收到|谢谢|感谢|已阅|知悉|明白|了解|好的?)(?:了)?\s*(.*)$/u.exec(text)
+  return !!receipt && (!receipt[1] || /^(?:你|您)?的?(?:结果|回复|反馈|确认|消息|通知|意见|结论)$/u.test(receipt[1]) || acknowledgement(receipt[1], false))
+}
+
+function reportLabel(text: string): boolean {
+  // Quoted paths/IDs were removed by mentionableText. These labels merely
+  // accompany a closing receipt; by themselves they never end a discussion.
+  return /^(?:交付状态[((]不变[))]|发布目录|交付目录|产物目录|入草稿箱(?:\s*Media\s*ID)?)\s*[::]?$/iu.test(text)
+}
+
+/** Only assistant output uses this policy. User mentions keep their exact API. */
+export function botRelayIntent(text: string, members: Member[]): {
+  mentionedIds: string[]; targetIds: string[]; acknowledgementOnly: boolean
+} {
+  const plain = mentionableText(text).replace(/\*\*|__/g, '')
+  const mentionedIds = mentionedAgents(plain, members)
+  const names = [...members.map(m => m.name), 'all', '所有人'].sort((a, b) => b.length - a.length)
+  const pattern = new RegExp(`(?<![A-Za-z0-9_.%+/@-])@(?:${names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?![A-Za-z0-9_])`, 'giu')
+  const targets = new Set<string>(), receipts = new Set<string>()
+  let subject: string[] = [], hasReceipt = false, allClosing = true, unscopedAction = false
+  for (const clause of plain.split(/[,,、。!?!?;;::\n]+/u).map(s => s.trim()).filter(Boolean)) {
+    const named = mentionedAgents(clause, members)
+    if (named.length) subject = named
+    const body = clause.replace(pattern, '').replace(/^[\s\-•]+|[\s.::~~]+$/gu, '').trim()
+    const closing = acknowledgement(body), label = reportLabel(body)
+    if (closing) { hasReceipt = true; for (const id of subject) receipts.add(id) }
+    else if (!label) {
+      allClosing = false
+      if (!subject.length) unscopedAction = true
+      for (const id of subject) targets.add(id)
+    }
+  }
+  return {
+    mentionedIds,
+    targetIds: mentionedIds.filter(id => unscopedAction || targets.has(id) || !receipts.has(id)),
+    acknowledgementOnly: hasReceipt && allClosing,
+  }
+}
+
+function canonical(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(canonical)
+  if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
+    .filter(([, v]) => v !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, canonical(v)]))
+  return value
+}
+
+function prose(text: string): string {
+  // Formatting whitespace is not progress, but whitespace within code, paths
+  // and business tool arguments/results may be significant. Leave those intact.
+  return text.normalize('NFC').split(/(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`]*`)/g)
+    .map((part, index) => index % 2 ? part : part.replace(/\s+/gu, ' ')).join('').trim()
+}
+
+/** Strip transport metadata only at the envelope level. IDs/times inside a
+ * tool's arguments or result are business data and must still count as change. */
+function toolValue(tool: Record<string, unknown>): unknown {
+  const metadata = new Set(['id', 'tool_id', 'call_id', 'callId', 'tool_call_id', 'request_id', 'requestId', 'event_id', 'eventId', 'session_id', 'turn_id', 'timestamp', 'createdAt', 'updatedAt', 'startedAt', 'completedAt', 'duration', 'durationMs', 'elapsedMs'])
+  return Object.fromEntries(Object.entries(tool).filter(([key]) => !metadata.has(key)))
+}
+
+export function relayMessageValue(message: WorkspaceMessage): unknown {
+  return {
+    role: message.role, agentId: message.agentId, content: prose(message.content), status: message.status, error: message.error,
+    tools: message.tools.map(toolValue),
+    // An artifact ID identifies a real deliverable, unlike a message/event ID.
+    attachments: message.attachments.map(({ createdAt: _time, conversationId: _chat, messageId: _message, ...file }) => file),
+  }
+}
+
+export function relayFingerprint(value: unknown): string {
+  return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
+}

+ 2 - 1
src/server/workspaceRuntime.ts

@@ -600,7 +600,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           c.kind === 'group'
             ? `你正在群聊「${c.name}」发言。群成员:${members.map((a) => `@${a.name} (id=${a.id})`).join('、')}。\n群规则:${c.instructions}\n${c.mode === 'host' ? (agent.id === c.administratorId ? '你是管理员。必要时用精确 @成员名称 委派工作;收到结果后复核并给用户结论。任务完成时不要继续 @。' : '执行当前委派任务。公开给出结果,由管理员复核;不要安排其他成员。') : '按自己的职责回复,只在需要协作时 @成员。不要重复已完成的工作。'}`
             : '',
-          run.requiredReply ? '你必须公开处理本次消息,直接回答、委派或澄清;禁止静默。管理员可按依赖一次 @一人,也可同时 @多人并行执行,整批结束后系统统一交回复核。' : run.replyMode === 'automatic' ? `你按自动参与配置收到消息。若与职责无关,禁止调用工具、禁止 @,完整答复只能是 ${NO_REPLY}。有关时正常回答。` : '',
+          c.kind === 'group' ? '只有安排具体的新工作时才用 @成员派工,并写明需要执行的动作。收到、感谢、审核通过、等待用户指令等确认不需要再次 @。任务收尾直接向用户报告结果;不要重复确认或把同一结果反复交回其他成员。' : '',
+          run.requiredReply ? '你必须公开处理本次消息,直接回答、委派或澄清;禁止静默。管理员可按依赖一次 @一人,也可同时 @多人并行执行,整批结束后系统统一交回复核。' : run.replyMode === 'automatic' ? `你按自动参与配置收到消息。若与职责无关或仅是已完成工作的重复确认,禁止调用工具、禁止 @,完整答复只能是 ${NO_REPLY}。有新工作或新结果时正常回答。` : '',
           `本轮用户指定成员:${this.store.require<Run>(owner, 'run', run.runId).mentionIds.map(id => members.find(a => a.id === id)).filter(Boolean).map(a => '@' + a!.name).join('、') || '未指定'}`,
           '角色规则不赋予额外工具权限;仍遵守基础 Hermes 的工具和安全约束。',
           this.store.require<Run>(owner, 'run', run.runId).internalInstruction || '',

+ 76 - 5
src/server/workspaceScheduler.ts

@@ -1,7 +1,7 @@
 import { randomUUID } from 'node:crypto'
 import { WorkspaceStore } from './workspaceStore.js'
 import { HttpError } from './errors.js'
-import { mentionedAgents } from './workspaceMentions.js'
+import { botRelayIntent, relayFingerprint, relayMessageValue, REPEATED_RELAY_NOTICE } from './workspaceRelay.js'
 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]]'
@@ -30,6 +30,12 @@ export interface Work {
   planned?: boolean
   silent?: boolean
   hadInteraction?: boolean
+  /** Durable assistant-to-assistant routing decisions, scoped to this run. */
+  relay?: {
+    fingerprints: Record<string, string>
+    suppressed: Record<string, 'acknowledgement' | 'repeated'>
+    notice?: boolean
+  }
   error?: string
   contextThroughSeq?: number
   turnConfiguration?: { mode: 'host' | 'free'; administratorId: string; members: Array<Pick<Agent, 'id' | 'name'>> }
@@ -171,18 +177,83 @@ export abstract class WorkspaceScheduler {
         }
       } else if (work.status === 'complete' && !work.silent && message) {
         const nextDepth = work.depth + 1
-        const mentions = mentionedAgents(message.content, config.members).filter(id => id !== work.agentId && c.memberIds.includes(id))
-        const targets = mentions.length ? mentions : config.mode === 'free' ? c.autoReplyIds.filter(id => id !== work.agentId && c.memberIds.includes(id)) : []
-        if (c.maxReplyRounds === -1 || nextDepth < c.maxReplyRounds) {
-          for (const target of targets) this.enqueue(owner, root, target, nextDepth, work.id, message.seq, mentions.includes(target) ? 'mentioned' : 'automatic', false)
+        const intent = botRelayIntent(message.content, config.members)
+        const eligible = (id: string) => id !== work.agentId && this.store.taskMemberIds(owner, c, work.conversationTaskId).includes(id)
+        const mentions = intent.targetIds.filter(eligible)
+        // A filtered mention is still an explicit address. Never turn a closing
+        // receipt back into a broadcast through the automatic participant list.
+        const automatic = config.mode === 'free' ? c.autoReplyIds.filter(eligible) : []
+        const targets = intent.mentionedIds.length ? mentions : intent.acknowledgementOnly ? [] : automatic
+        work.relay = { fingerprints: {}, suppressed: {} }
+        for (const target of intent.mentionedIds.filter(eligible)) {
+          if (!mentions.includes(target)) work.relay.suppressed[target] = 'acknowledgement'
+        }
+        if (intent.acknowledgementOnly && !intent.mentionedIds.length)
+          for (const target of automatic) work.relay.suppressed[target] = 'acknowledgement'
+        if (targets.length && (c.maxReplyRounds === -1 || nextDepth < c.maxReplyRounds)) {
+          const works = this.works(owner, root.id).filter(t => t.conversationId === c.id && t.conversationTaskId === work.conversationTaskId)
+          const evidence = this.relayEvidence(owner, work, works)
+          const previous = works.filter(t => t.id !== work.id && t.agentId === work.agentId && t.status === 'complete' && t.planned)
+          const legacyEvidence = new Map<string, unknown>()
+          for (const target of targets) {
+            const fingerprint = evidence === undefined ? undefined : relayFingerprint({ target, evidence })
+            if (fingerprint) work.relay.fingerprints[target] = fingerprint
+            const repeated = fingerprint && previous.some(t => {
+              if (t.relay) return t.relay.fingerprints[target] === fingerprint
+              // Pre-upgrade turns have no receipt. Only a persisted child is
+              // evidence that this particular edge was actually dispatched.
+              if (!works.some(child => child.batchId === t.id && child.agentId === target && !child.reviewOf)) return false
+              if (!legacyEvidence.has(t.id)) legacyEvidence.set(t.id, this.relayEvidence(owner, t, works))
+              const old = legacyEvidence.get(t.id)
+              return old !== undefined && relayFingerprint({ target, evidence: old }) === fingerprint
+            })
+            if (repeated) { work.relay.suppressed[target] = 'repeated'; continue }
+            this.enqueue(owner, root, target, nextDepth, work.id, message.seq, mentions.includes(target) ? 'mentioned' : 'automatic', false)
+          }
+          if (Object.values(work.relay.suppressed).includes('repeated') && !works.some(t => t.relay?.notice)) {
+            work.relay.notice = true
+            this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, conversationTaskId: root.conversationTaskId, seq: 0, role: 'system', content: REPEATED_RELAY_NOTICE, reasoning: '', status: 'complete', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
+          }
         } else if (targets.length && !this.store.get(owner, 'limit-notice', root.id)) {
           this.store.put(owner, 'limit-notice', root.id, { id: root.id })
           this.store.saveMessage(owner, { id: randomUUID(), conversationId: c.id, conversationTaskId: root.conversationTaskId, seq: 0, role: 'system', content: '已达到自动协作轮数上限,本轮不再自动分派。', reasoning: '', status: 'complete', runId: root.id, attachments: [], tools: [], createdAt: Date.now() })
         }
+        this.store.put(owner, 'turn', work.id, work)
       }
       this.updateRoot(owner, root.id)
     })
   }
+  private relayEvidence(owner: string, work: Work, works: Work[]): unknown | undefined {
+    const read = (id?: string) => {
+      const message = id ? this.store.get<Message>(owner, 'message', id) : undefined
+      return message?.conversationId === work.conversationId && message.conversationTaskId === work.conversationTaskId && message.runId === work.runId ? message : undefined
+    }
+    const reply = read(work.currentMessageId)
+    if (!reply) return undefined
+    const inputs = work.reviewOf ? works.filter(t => t.batchId === work.reviewOf && !t.reviewOf).sort((a, b) => a.agentId.localeCompare(b.agentId))
+      : works.filter(t => t.id === work.batchId)
+    let input: unknown
+    if (inputs.length) {
+      input = inputs.map(t => {
+        const message = read(t.currentMessageId)
+        return { agentId: t.agentId, status: t.status, error: t.error, message: message ? relayMessageValue(message) : null }
+      })
+    } else {
+      // Initial turns and the legacy admission path have no parent Work.
+      const row = this.store.db.prepare("SELECT data FROM workspace_entities WHERE owner=? AND kind='message' AND json_extract(data,'$.conversationId')=? AND json_extract(data,'$.seq')=?").get(owner, work.conversationId, work.triggerSeq)
+      const trigger = row ? read((JSON.parse(String(row.data)) as Message).id) : undefined
+      if (!trigger) return undefined
+      input = relayMessageValue(trigger)
+    }
+    const relevant = new Set([work.id, ...inputs.map(t => t.id)])
+    const interactions = this.store.list<WorkspaceInteraction & { answer?: string; responseState?: string }>(owner, 'interaction')
+      .filter(i => i.runId === work.runId && i.conversationId === work.conversationId && i.conversationTaskId === work.conversationTaskId && i.answer !== undefined && i.responseState === 'sent'
+        && relevant.has(this.store.get<{ taskId: string }>(owner, 'interaction-binding', i.id)?.taskId ?? ''))
+      // A fresh human answer is permission to advance, even if the person
+      // chooses the same answer again. This is not a provider event ID.
+      .map(i => ({ id: i.id, kind: i.kind, question: i.message, answer: i.answer })).sort((a, b) => a.id.localeCompare(b.id))
+    return { version: 1, agentId: work.agentId, mode: work.turnConfiguration?.mode, input, reply: relayMessageValue(reply), interactions }
+  }
   private pump(): void {
     if (this.pumping || this.closing) return
     this.pumping = true

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

@@ -10,6 +10,8 @@ import {HttpError} from '../../src/server/errors'
 import {WorkspaceAssets} from '../../src/server/workspaceAssets'
 import { WorkspaceStore } from '../../src/server/workspaceStore'
 import { WorkspaceRuntime, mentionedAgents } from '../../src/server/workspaceRuntime'
+import type { Work } from '../../src/server/workspaceScheduler'
+import { REPEATED_RELAY_NOTICE } from '../../src/server/workspaceRelay'
 import { WorkspaceNodes } from '../../src/server/workspaceGateway'
 import { UploadStore } from '../../src/server/uploads'
 import type {
@@ -399,6 +401,9 @@ describe('Web-owned workspace', () => {
       },
     })), 5) }
     const firstRun=runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '任务甲' })
+    // Different tasks admitted in the same millisecond need not win a lane in
+    // 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))
     const first=store.list<WorkspaceInteraction>(owner,'interaction').find(i=>i.conversationTaskId===firstTask.id)!
@@ -900,6 +905,155 @@ function completeReply(socket: WebSocket, p: Record<string, any>, text: string,
 }
 const speaker = (p: Record<string, any>) => /^你是 (.*?)。/.exec(p.text)?.[1]
 
+describe('automatic relay termination', () => {
+  const receipt = '收到 @审核 终审维持通过。\n\n交付状态(不变):入草稿箱 Media ID `draft-123`,发布目录 `/work/final/`。\n等民哥群发指令。'
+  const review = '@竹儿 维持通过,等民哥群发指令。无新事实、无新派工,本轮不重复核验。'
+  const promptCount = () => requests.filter(r => r.method === 'prompt.submit').length
+  async function idle(runId: string, conversationId: string) {
+    await finished(runId)
+    const count = promptCount()
+    await new Promise(resolve => setTimeout(resolve, 80))
+    expect(promptCount()).toBe(count)
+    expect(store.list<Work>(owner, 'turn').filter(w => w.runId === runId && (!w.planned || !['complete', 'failed', 'interrupted'].includes(w.status)))).toEqual([])
+    const summary = store.conversationSummary(owner, store.require(owner, 'conversation', conversationId))
+    expect(summary.activeRunId).toBeUndefined()
+    expect(summary.queuedMessageCount ?? 0).toBe(0)
+  }
+  it('finishes the observed host receipt cycle in exactly three calls and accepts a new user message', async () => {
+    const a = agent('竹儿'), b = agent('审核')
+    const g = store.createGroup(owner, { name: '收尾验收', memberIds: [a.id, b.id], administratorId: a.id, maxReplyRounds: -1 })
+    let admin = 0
+    reply = (socket, p) => completeReply(socket, p, speaker(p) === a.name ? admin++ === 0 ? '@审核 请审核文案' : receipt : review)
+    const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '请完成文案' })
+    await idle(root.id, g.id)
+    expect(promptCount()).toBe(3)
+    expect(store.messages(owner, g.id).map(m => m.content)).toEqual(['请完成文案', '@审核 请审核文案', review, receipt])
+    expect(store.list<Work>(owner, 'turn').find(w => w.reviewOf)?.relay?.suppressed[b.id]).toBe('acknowledgement')
+    const next = runtime.send(owner, g.id, { requestId: randomUUID(), content: '好的。' })
+    await idle(next.id, g.id)
+    expect(promptCount()).toBe(4)
+  })
+  it.each(['感谢 @审核,等待用户指令。', '收到,等待用户指令。'])('does not turn a free-mode receipt into an automatic broadcast: %s', content => {
+    const a = agent('竹儿'), b = agent('审核')
+    const g = store.createGroup(owner, { name: '自动收尾', memberIds: [a.id, b.id], administratorId: a.id, mode: 'free', autoReplyIds: [b.id], maxReplyRounds: -1 })
+    reply = (socket, p) => completeReply(socket, p, content)
+    return (async () => {
+      const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@竹儿 请处理' })
+      await idle(root.id, g.id)
+      expect(promptCount()).toBe(1)
+      const next = runtime.send(owner, g.id, { requestId: randomUUID(), content: '感谢 @审核' })
+      await idle(next.id, g.id)
+      expect(speaker(requests.filter(r => r.method === 'prompt.submit').at(-1)!.params)).toBe(b.name)
+    })()
+  })
+  it.each([
+    ['host', 2, 5], ['host', 3, 7], ['free', 2, 4], ['free', 3, 5],
+  ] as const)('bounds unchanged %s cooperation with %i members at %i calls', async (mode, size, expected) => {
+    const members = [agent('甲'), agent('乙'), ...(size === 3 ? [agent('丙')] : [])]
+    const g = store.createGroup(owner, { name: '重复往返', memberIds: members.map(a => a.id), administratorId: members[0]!.id, mode, maxReplyRounds: -1 })
+    let count = 0
+    reply = (socket, p) => {
+      const index = members.findIndex(a => a.name === speaker(p)), whitespace = count++ % 2 ? '  ' : ' '
+      const text = mode === 'host' ? index === 0 ? members.slice(1).map(a => `@${a.name}${whitespace}再检查一次`).join(',') : '检查结果相同'
+        : `@${members[(index + 1) % members.length]!.name}${whitespace}再检查一次`
+      completeReply(socket, p, count > 20 ? '完成' : text)
+      completeReply(socket, p, count > 20 ? '完成' : text) // Duplicate completion must not double-plan.
+    }
+    const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@甲 开始' })
+    await idle(root.id, g.id)
+    expect(count).toBe(expected)
+    expect(store.messages(owner, g.id).filter(m => m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
+    expect(store.list<Work>(owner, 'turn').some(w => Object.values(w.relay?.suppressed ?? {}).includes('repeated'))).toBe(true)
+  })
+  it.each(['result', 'tool', 'attachment', 'interaction'] as const)('allows repeated instructions with new %s evidence beyond thirteen calls', async kind => {
+    const a = agent('负责人'), b = agent('审核')
+    const g = store.createGroup(owner, { name: '持续推进', memberIds: [a.id, b.id], administratorId: a.id, maxReplyRounds: -1 })
+    let admin = 0, worker = 0
+    reply = (socket, p) => {
+      if (speaker(p) === a.name) completeReply(socket, p, admin++ < 7 ? '感谢 @审核,请重新检查第二段' : '完成')
+      else { worker++; completeReply(socket, p, kind === 'result' ? `第 ${worker} 版结果` : '本次检查结果') }
+    }
+    runtime.onMessage = async (_owner, message) => {
+      if (message.agentId !== b.id) return
+      if (kind === 'tool') message.tools = [{ id: randomUUID(), name: 'verify', status: 'tool.complete', result: { revision: worker } }]
+      if (kind === 'attachment') message.attachments = [{ id: randomUUID(), name: 'revision.txt', mimeType: 'text/plain', size: 10, createdAt: Date.now() }]
+      if (kind === 'interaction') {
+        const id = randomUUID()
+        store.put(owner, 'interaction', id, { id, runId: message.runId, conversationId: g.id, conversationTaskId: message.conversationTaskId, agentId: b.id, kind: 'clarification', message: '继续重试?', answer: '继续', responseState: 'sent', resolved: true })
+        store.put(owner, 'interaction-binding', id, { taskId: message.taskId })
+      }
+      store.saveMessage(owner, message)
+    }
+    const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '反复改进' })
+    await idle(root.id, g.id)
+    expect(promptCount()).toBe(15)
+    expect(store.messages(owner, g.id).some(m => m.content === REPEATED_RELAY_NOTICE)).toBe(false)
+  })
+  it('ends only the repeating free-mode branch while an independent member finishes', async () => {
+    const a = agent('甲'), b = agent('乙'), c = agent('独立成员')
+    const g = store.createGroup(owner, { name: '分支隔离', memberIds: [a.id, b.id, c.id], administratorId: a.id, mode: 'free', maxReplyRounds: -1 })
+    let pending: { socket: WebSocket; p: Record<string, any> } | undefined, loops = 0
+    reply = (socket, p) => {
+      if (speaker(p) === c.name) { pending = { socket, p }; return }
+      completeReply(socket, p, ++loops > 15 ? '完成' : speaker(p) === a.name ? '@乙 再检查' : '@甲 再检查')
+    }
+    const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@甲 @独立成员 请分别处理' })
+    await vi.waitFor(() => expect(store.messages(owner, g.id).some(m => m.content === REPEATED_RELAY_NOTICE)).toBe(true))
+    expect(store.require<WorkspaceRun>(owner, 'run', root.id).status).toBe('running')
+    expect(store.list<Work>(owner, 'turn').filter(w => w.cancelRequested)).toEqual([])
+    expect(loops).toBe(4)
+    completeReply(pending!.socket, pending!.p, '完成')
+    await idle(root.id, g.id)
+    expect(store.messages(owner, g.id).find(m => m.agentId === c.id)).toMatchObject({ content: '完成', status: 'complete' })
+  })
+  it('checks each target independently when a new automatic member joins the same repeated reply', async () => {
+    const a = agent('甲'), b = agent('乙'), c = agent('丙')
+    const g = store.createGroup(owner, { name: '目标隔离', memberIds: [a.id, b.id, c.id], administratorId: a.id, mode: 'free', autoReplyIds: [a.id], maxReplyRounds: -1 })
+    let calls = 0, admin = 0
+    reply = (socket, p) => { calls++; completeReply(socket, p, calls > 15 ? '完成' : speaker(p) === a.name ? '@乙 再检查' : speaker(p) === b.name ? '继续检查' : '完成') }
+    runtime.onMessage = async (_owner, message) => {
+      if (message.agentId === b.id && ++admin === 2) store.updateConversation(owner, g.id, { autoReplyIds: [a.id, c.id] })
+    }
+    const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '@甲 开始' })
+    await idle(root.id, g.id)
+    expect(calls).toBe(5)
+    const blocked = store.list<Work>(owner, 'turn').find(w => w.relay?.suppressed[a.id] === 'repeated')!
+    expect(blocked.relay?.suppressed[c.id]).toBeUndefined()
+    expect(store.list<Work>(owner, 'turn').find(w => w.batchId === blocked.id && w.agentId === c.id)?.status).toBe('complete')
+  })
+  it('starts fresh for identical new requests in the same task and a different task', async () => {
+    const a = agent('甲'), b = agent('乙')
+    const g = store.createGroup(owner, { name: '任务隔离', memberIds: [a.id, b.id], administratorId: a.id, mode: 'free', maxReplyRounds: -1 })
+    let calls = 0
+    reply = (socket, p) => { calls++; completeReply(socket, p, calls > 20 ? '完成' : speaker(p) === a.name ? '@乙 再检查' : '@甲 再检查') }
+    const originalTask = defaultTask(g.id), secondTask = store.createTask(owner, g.id, { title: '另一个任务' })
+    for (const [index, task] of [originalTask, originalTask, secondTask].entries()) {
+      const root = runtime.send(owner, g.id, { requestId: randomUUID(), taskId: task.id, content: '@甲 开始' })
+      await idle(root.id, g.id)
+      expect(calls).toBe((index + 1) * 4)
+      expect(store.messages(owner, g.id, Number.MAX_SAFE_INTEGER, 100, false, task.id).filter(m => m.runId === root.id && m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
+    }
+  })
+  it.each([false, true])('recovers repeat suppression across restart before planning (legacy=%s)', async legacy => {
+    const a = agent('负责人'), b = agent('成员')
+    const g = store.createGroup(owner, { name: '循环恢复', memberIds: [a.id, b.id], administratorId: a.id, maxReplyRounds: -1 })
+    let calls = 0
+    reply = (socket, p) => { calls++; completeReply(socket, p, calls > 15 ? '完成' : speaker(p) === a.name ? '@成员 再检查' : '结果相同') }
+    runtime.onMessage = async () => { if (calls === 5) runtime.close() }
+    const root = runtime.send(owner, g.id, { requestId: randomUUID(), content: '开始' })
+    await vi.waitFor(() => expect(store.list<Work>(owner, 'turn').filter(w => w.status === 'complete')).toHaveLength(5))
+    if (legacy) for (const work of store.list<Work>(owner, 'turn')) { delete work.relay; store.put(owner, 'turn', work.id, work) }
+    runtime = new WorkspaceRuntime(store, nodes, uploads); runtime.start()
+    await idle(root.id, g.id)
+    expect(calls).toBe(5)
+    expect(store.messages(owner, g.id).filter(m => m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
+    runtime.close(); runtime = new WorkspaceRuntime(store, nodes, uploads); runtime.start()
+    await idle(root.id, g.id)
+    expect(calls).toBe(5)
+    expect(store.messages(owner, g.id).filter(m => m.content === REPEATED_RELAY_NOTICE)).toHaveLength(1)
+  })
+})
+
 it('starts with the administrator, executes a batch concurrently, and reviews failures once without spending a round', async () => {
   const a = agent('管理员'), b = agent('成员乙'), c = agent('成员丙')
   const group = store.createGroup(owner, { name: '并行验收', memberIds: [a.id,b.id,c.id], administratorId: a.id, maxReplyRounds: 2 })

+ 74 - 0
tests/server/workspaceRelay.test.ts

@@ -0,0 +1,74 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest'
+import { botRelayIntent, relayFingerprint, relayMessageValue } from '../../src/server/workspaceRelay'
+import { mentionedAgents } from '../../src/server/workspaceMentions'
+import type { WorkspaceMessage } from '../../src/shared/workspace'
+
+const members = [{ id: 'reviewer', name: '审核' }, { id: 'writer', name: '文案' }, { id: 'lead', name: '竹儿' }]
+describe('assistant relay intent', () => {
+  it.each([
+    '收到 @审核 终审维持通过。\n\n交付状态(不变):入草稿箱 Media ID `abc123`,发布目录 `/work/final/`。\n等民哥群发指令。',
+    '@竹儿 维持通过,**等民哥群发指令**。无新事实、无新派工,本轮不重复核验。',
+    '感谢 @审核,等待用户指令。',
+    '@审核 已通过。',
+    '收到,等待用户指令。',
+    'Thanks @审核.\nWaiting for your input',
+  ])('does not dispatch a closing receipt: %s', text => {
+    expect(botRelayIntent(text, members)).toMatchObject({ targetIds: [], acknowledgementOnly: true })
+  })
+  it.each([
+    '感谢 @审核,请重新检查第二段。',
+    '收到 @审核,第二段还有一个问题。',
+    '请@审核检查接口,@文案检查数据',
+    '@审核 第一步',
+    '@审核 继续',
+    '@审核',
+    '请复核:@审核',
+    '请 @审核 等待数据库恢复后重试',
+    '@审核 不要修改第一段,请修改第二段',
+  ])('keeps new or ambiguous assignments: %s', text => {
+    expect(botRelayIntent(text, members).targetIds).toContain('reviewer')
+  })
+  it('filters targets individually and preserves repeated mention overrides', () => {
+    expect(botRelayIntent('感谢 @审核,@文案 请修改第二段', members).targetIds).toEqual(['writer'])
+    expect(botRelayIntent('感谢 @审核。@文案 已收到。请 @审核 重新检查。', members).targetIds).toEqual(['reviewer'])
+  })
+  it('keeps user routing unchanged and ignores quoted, code, URL and email mentions', () => {
+    expect(mentionedAgents('感谢 @审核', members)).toEqual(['reviewer'])
+    expect(botRelayIntent('收到。\n> @审核\n```\n@文案\n```\n`@竹儿` <quoted_message>@审核</quoted_message>', members).targetIds).toEqual([])
+  })
+  it('leaves unfamiliar text and long repetitive text to the cycle guard', () => {
+    expect(botRelayIntent('本轮还缺少数据', members).acknowledgementOnly).toBe(false)
+    expect(botRelayIntent('收到'.repeat(16000) + ' @审核', members).targetIds).toEqual(['reviewer'])
+  })
+})
+
+const message = (): WorkspaceMessage => ({
+  id: 'message-1', conversationId: 'chat', seq: 1, role: 'assistant', agentId: 'reviewer', content: '检查 /work/final 版本 2',
+  reasoning: 'thinking', status: 'complete', createdAt: 1,
+  tools: [{ id: 'event-1', name: 'check', status: 'tool.complete', createdAt: 1, arguments: { id: 10 }, result: { timestamp: 123, count: 2 } }],
+  attachments: [{ id: 'file-1', name: 'report.txt', mimeType: 'text/plain', size: 12, createdAt: 1, messageId: 'message-1' }],
+})
+const key = (m: WorkspaceMessage) => relayFingerprint(relayMessageValue(m))
+describe('relay progress fingerprints', () => {
+  it('ignores delivery IDs, timestamps, reasoning, whitespace and object key order', () => {
+    const original = message(), other = message()
+    other.id = 'message-2'; other.seq = 200; other.createdAt = 10; other.reasoning = 'new thinking'
+    other.content = ' 检查   /work/final\n版本 2 '
+    other.tools[0] = { ...other.tools[0], id: 'event-2', createdAt: 20, durationMs: 44 }
+    other.attachments[0] = { ...other.attachments[0]!, messageId: 'message-2', createdAt: 20 }
+    expect(key(other)).toBe(key(original))
+    expect(relayFingerprint({ a: 1, b: 2 })).toBe(relayFingerprint({ b: 2, a: 1 }))
+  })
+  it.each(['content', 'path', 'toolArgument', 'toolResult', 'artifact', 'codeWhitespace', 'argumentWhitespace'] as const)('preserves new business evidence: %s', field => {
+    const original = message(), other = message()
+    if (field === 'content') other.content = '检查 /work/final 版本 3'
+    if (field === 'path') other.content = '检查 /work/next 版本 2'
+    if (field === 'toolArgument') other.tools[0]!.arguments = { id: 11 }
+    if (field === 'toolResult') other.tools[0]!.result = { timestamp: 124, count: 2 }
+    if (field === 'artifact') other.attachments[0]!.id = 'file-2'
+    if (field === 'codeWhitespace') { original.content = '`/work/a b`'; other.content = '`/work/a  b`' }
+    if (field === 'argumentWhitespace') { original.tools[0]!.arguments = { path: '/work/a b' }; other.tools[0]!.arguments = { path: '/work/a  b' } }
+    expect(key(other)).not.toBe(key(original))
+  })
+})