Procházet zdrojové kódy

实现持久团队任务编排与跨端结果回传

LSamien před 1 měsícem
rodič
revize
f79318fb8b

+ 7 - 1
desktop/main.mjs

@@ -51,7 +51,13 @@ else {
       title: '夭夭', backgroundColor: '#f7f7f7',
       webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
         webSecurity: true, preload: join(desktopRoot, 'preload.cjs') } })
-    window.webContents.setWindowOpenHandler(({ url }) => { safeExternal(url); return { action: 'deny' } })
+    window.webContents.setWindowOpenHandler(({ url }) => {
+      try {
+        if (manager?.state.url && new URL(url).origin === manager.state.url) void window.loadURL(url)
+        else safeExternal(url)
+      } catch { /* Invalid navigation is denied. */ }
+      return { action: 'deny' }
+    })
     const guardNavigation = (event, url) => {
       if (url === bootURL || (manager?.state.url && new URL(url).origin === manager.state.url)) return
       event.preventDefault(); safeExternal(url)

+ 6 - 3
docs/agent-platform-implementation.md

@@ -21,9 +21,9 @@
 - [x] 基础自主创建 Agent、团队、启动和查询任务,以及按轮次授权(`ac62d07`)。
 - [ ] 持久 Agent、临时助手、团队角色分离;记录创建来源与可恢复生命周期。
 - [ ] 子任务负责人、依赖、验收条件、产物归属、结构化分派和计划调整。
-- [ ] 成功/失败结果自动回到原会话,忙碌时排队;归档、停止、撤权后不复活旧任务。
-- [ ] 区分执行结束与目标完成;复核、返工、等待用户、受阻状态可追踪。
-- [ ] 持久投递与去重、重启修复、自动唤醒/递归/配额限制。
+- [x] 成功/失败结果自动回到原会话,忙碌时排队;归档、停止、撤权后不复活旧任务。
+- [x] 区分执行结束与目标完成;复核、返工、等待用户、受阻状态可追踪。
+- [x] 持久投递与去重、重启修复、自动唤醒/递归/配额限制。
 - [ ] Agent、桌面和共享写入资源的并发限制;取消只影响所属任务。
 
 ### 执行节点与电脑
@@ -59,3 +59,6 @@
 - 桌面基础:开发启动与打包后 App 均通过 `desktop/lifecycle.test.mjs`,实际测试服务启动、窗口关闭后存活、强制结束服务后的恢复、外部连接不误停服务、退出清理。
 - 2026-09-08 首个桌面快照:arm64 App,开发证书签名;严格签名验证和 DMG 校验通过。DMG SHA-256:`611995fea080455da3a87a561cb5a7465a70e7437a91b948d45ce346822b0095`。这是阶段产物,尚未公证、发布或替换正式服务。
 - 桌面基础对应服务全量测试 652 项通过;任务协调器正在继续实现,不能据此宣称后续任务或 VM 已完成。
+- 任务协调阶段:类型检查、666 项全量测试及真实 Hermes 隔离协议验收通过。真实协议验证包含持久成员、组队、具体成员分派、成员不继承组队工具、管理员验收、目标完成、原会话结果及双管理员并发工具隔离。
+- Web 验证任务状态、结果跳转和独立文字/附件草稿;iOS 验证任务进度、停止和结构化任务跳转;Android API 31 模拟器验证对应工作流。测试日志在本轮工具记录中,后续完整交付仍需重新核对最终代码。
+- 下一阶段必须继续执行节点、受控 Worker、VM、临时助手、资源租约、文件/产物搬运、后台常驻、更新回退等未完成项。当前 App 安装包是桌面基础快照,不是最终完整平台交付。

+ 12 - 2
docs/bot-agent-teams.md

@@ -10,7 +10,7 @@
 
 Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员和团队、启动独立团队任务、读取进展和结果。新团队自动加入发起者并将其设为管理员,随后使用已有的 @分派和管理员复核机制。成员、团队和任务写入当前账号的 `workspace.sqlite3`,发布既有变更事件供 Web/iOS 同步。
 
-启动任务返回「已启动,尚未完成」。工作在新团队的任务内继续,结果在那里显示;发起者可使用查询工具读取结果。并未新增回到原单聊的自动回传机制,也不会把队列完成当成用户目标已经达成。
+启动任务返回「已启动,尚未完成」。工作在团队任务内继续。子任务执行结束后,结果自动回到原会话并唤醒管理员复核;可通过「打开任务」进入具体任务。执行结束与目标完成分开记录,完成必须满足子任务验收和目标检查。
 
 ## 运行条件
 
@@ -30,6 +30,12 @@ Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员
 | `workspace_create_team` | 创建管理员协作模式的团队 |
 | `workspace_start_team_task` | 在发起者管理的团队启动任务 |
 | `workspace_get_team_task` | 读取状态、最近 20 条消息和待处理问题 |
+| `workspace_assign_task` | 分派具有负责人、依赖和验收条件的子任务 |
+| `workspace_update_assignment` / `workspace_cancel_assignment` | 调整或停止子任务,保留原因与历史 |
+| `workspace_review_assignment` | 验收、明确返工或记录受阻 |
+| `workspace_finish_team_task` / `workspace_resume_team_task` | 记录目标结论,或从用户新指令恢复目标 |
+| `workspace_update_created_agent` / `workspace_archive_created_agent` | 管理自己创建且符合使用状态限制的成员 |
+| `workspace_update_team` | 调整空闲团队的规则、成员或归档状态 |
 
 所有变更工具要求 `requestId`(UUID);重试同一操作必须复用它。服务端持久保存回执;相同编号用于不同参数返回冲突,重复请求不会重复创建。原生工具调用 ID 也在本轮去重。
 
@@ -37,7 +43,11 @@ Agent 可以查看可用基础 Agent、复用已有成员、创建持久成员
 
 每轮使用独立随机凭据、独立原生工具 ID 和回环监听端口,凭据仅传给经过认证的工具桥绑定接口。权限、账号、Profile、成员归属、归档和停止状态在每次调用时校验,异步操作在写入前再次校验。绑定保留 Agent 的配置版本;关闭后重新开启权限也不能使旧轮次重新生效。完成、取消、连接结束或服务关闭时销毁监听器并解绑;最长 30 分钟,运行时每 5 分钟续租。断线恢复只核对原执行,不重新提交已有工具副作用;下一轮重新授权。
 
-自动组队的范围:每队 2–8 人,最多管理 20 个有效团队,账号最多保留 100 个有效 Agent,默认协作 6 轮、可设置 1–12 轮,每轮最多 128 次团队工具调用。运行并发沿用现有调度器。手工编辑团队仍走原有界面和接口。
+自动组队的范围:每队 2–8 人,最多管理 20 个有效团队,账号最多保留 100 个有效 Agent,每轮最多 128 次团队工具调用。结构化目标最多 32 个子任务,每次授权默认允许子任务执行 3 次、目标自动唤醒 8 次。恢复旧目标需要用户新指令,并确认旧执行已结束。同一持久 Agent 跨会话和任务串行执行;不同 Agent 保留全局最多 4 个执行槽。用户指令优先于后台分派,排队超过 60 秒的工作会提升优先级。
+
+停止与停止完成分别表示为 `cancelling`、`cancelled`,状态不确定时不重放副作用。结果投递、复核轮次和恢复代次都持久保存并去重。账号授权版本或组队权限变化使旧目标和工具授权失效。
+
+Web、iOS、Android 均可查看任务分工、验收和结果,停止或继续当前目标。Web 的任务选择会保留各自的文字、引用及附件草稿。macOS App 与 Web 共用界面,桌面托管实现位于 `desktop/`。
 
 ## 验证
 

+ 1 - 0
src/client/components/composer/ComposerShell.vue

@@ -392,6 +392,7 @@ onBeforeUnmount(() => {
 
 defineExpose({
   attachFiles: addFiles,
+  filesSnapshot: () => attachments.value.map(attachment => attachment.file),
   focus: () => textarea.value?.focus(),
   clearAfterSend,
 })

+ 33 - 0
src/client/components/workspace/TaskPlan.vue

@@ -0,0 +1,33 @@
+<script setup lang="ts">
+import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
+import type { WorkspaceAgent } from '@shared/workspace'
+defineProps<{ goal?: AgentGoal; assignments: AgentAssignment[]; agents: WorkspaceAgent[] }>()
+defineEmits<{stop:[];resume:[]}>()
+const labels: Record<string,string> = {pending:'待执行',running:'进行中',review:'待复核',complete:'已完成',failed:'执行失败',blocked:'遇到阻碍',cancelling:'正在停止',cancelled:'已停止',waiting:'需要补充信息'}
+</script>
+<template>
+  <details v-if="goal || assignments.length" class="task-plan">
+    <summary>任务进度 <span v-if="goal">{{ labels[goal.status] }}</span></summary>
+    <ol v-if="assignments.length">
+      <li v-for="item in assignments" :key="item.id">
+        <div><strong>{{ item.title }}</strong><span>{{ labels[item.status] }}</span></div>
+        <small>{{ agents.find(a => a.id === item.agentId)?.name || '成员不可用' }}</small>
+        <p v-if="item.review">{{ item.review }}</p>
+      </li>
+    </ol>
+    <p v-if="goal?.result" class="result">{{ goal.result }}</p>
+    <ul v-if="goal?.acceptanceCriteria.length" class="criteria">
+      <li v-for="(criterion,index) in goal.acceptanceCriteria" :key="index">
+        <span>{{ goal.checks?.some(check => check.criterion === index && check.passed) ? '已验收' : '待验收' }}</span> {{ criterion }}
+      </li>
+    </ul>
+    <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
+    <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
+  </details>
+</template>
+<style scoped>
+.task-plan{margin:0 18px 10px;border:1px solid var(--line);border-radius:12px;padding:0 14px;font-size:13px;max-height:34vh;overflow:auto}
+summary{min-height:44px;display:flex;align-items:center;gap:12px;cursor:pointer;list-style:revert}summary span,small{color:var(--text-muted)}
+button{min-height:44px;padding:8px 12px;margin-bottom:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}
+ol{padding-left:18px;margin-top:0}li{padding:7px 0}li div{display:flex;gap:12px;justify-content:space-between}li div span{white-space:nowrap;color:var(--text-muted)}p{white-space:pre-wrap;line-height:1.6}.criteria{padding-left:18px}.criteria span{font-size:12px;color:var(--text-muted)}summary:focus-visible{outline:2px solid var(--text-primary);outline-offset:3px}
+</style>

+ 97 - 14
src/client/views/ConversationsView.vue

@@ -6,6 +6,9 @@ import { useRoute, useRouter } from 'vue-router'
 import WorkspaceShell from '@/components/app/WorkspaceShell.vue'
 import RemoteAgentPicker from '@/components/workspace/RemoteAgentPicker.vue'
 import ConversationList from '@/components/workspace/ConversationList.vue'
+import TaskPlan from '@/components/workspace/TaskPlan.vue'
+import type { AgentAssignment } from '@shared/agentTasks'
+import type { WorkspaceTask } from '@shared/workspace'
 import FloatingResourceSearch from '@/components/app/FloatingResourceSearch.vue'
 import type { SidebarItem } from '@/components/app/types'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
@@ -27,7 +30,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import { workspaceAvatarMembers, workspaceAvatarState, workspaceConversationItem, workspaceMessagesToUi } from '@/components/workspace/viewModels'
 import { useAuthStore } from '@/stores/auth'
 import { useThemeStore } from '@/stores/theme'
-import { apiRequest } from '@/api/client'
+import { apiRequest, ApiError } from '@/api/client'
 import { updateProfileIdentity, type ProfileIdentityInput } from '@/api/profiles'
 import { encodeAgentAvatar, randomAgentIdentity } from '@shared/agentIdentity'
 import type { JsonValue } from '@shared/types'
@@ -53,6 +56,10 @@ const active = ref<Conversation>(),
   run = ref<Run | null>(null),
   interactions = ref<Interaction[]>([]),
   context = ref<Record<string, unknown> | null>(null)
+const activeTask = ref<WorkspaceTask | null>(null), tasks = ref<WorkspaceTask[]>([]), assignments = ref<AgentAssignment[]>([])
+const taskFiles = new Map<string,File[]>()
+const taskQuotes = new Map<string,UiMessage | null>()
+const composerKey = computed(() => `${auth.user?.id}:${active.value?.id}${activeTask.value ? `:${activeTask.value.id}` : ''}`)
 const text = ref(''),
   error = ref(''),
   busy = ref(false),
@@ -175,6 +182,7 @@ let cursor = 0,
   pendingRequestId: string | undefined,
   pendingFingerprint = ''
 const selected = computed(() => (typeof route.params.id === 'string' ? route.params.id : undefined))
+const selectedTask = computed(() => typeof route.query.taskId === 'string' ? route.query.taskId : '')
 const members = computed(() => agents.value.filter((a) => active.value?.memberIds.includes(a.id)))
 const isAgentDialog = computed(() => dialog.value === 'agent' || dialog.value === 'editAgent')
 const body = (v: unknown) => v as JsonValue
@@ -212,9 +220,12 @@ async function load(id = selected.value, append = false) {
     run.value = null
     interactions.value = []
     context.value = null
+    activeTask.value = null;tasks.value = [];assignments.value = []
     return
   }
   const own = ++generation
+  const previousComposer = composerKey.value
+  const requestedTask = selectedTask.value || (active.value?.id === id ? activeTask.value?.id : undefined)
   if (!append) loading.value = true
   try {
     const r = await apiRequest<{
@@ -224,10 +235,14 @@ async function load(id = selected.value, append = false) {
       interactions: Interaction[]
       context: Record<string, unknown> | null
       hiddenMessageIds?: string[]
-    }>(`/api/app/conversations/${id}`)
+      task?: WorkspaceTask | null
+      tasks?: WorkspaceTask[]
+      assignments?: AgentAssignment[]
+    }>(`/api/app/conversations/${id}${requestedTask ? `?taskId=${encodeURIComponent(requestedTask)}` : ''}`)
     if (disposed || own !== generation) return
     const atBottom = timeline.value?.isFollowingBottom() ?? true
     active.value = r.conversation
+    activeTask.value = r.task ?? null;tasks.value = r.tasks ?? [];assignments.value = r.assignments ?? []
     messages.value = append
       ? [...new Map([...messages.value, ...r.messages].map((m) => [m.id, m])).values()].sort(
           (a, b) => a.seq - b.seq,
@@ -237,6 +252,22 @@ async function load(id = selected.value, append = false) {
     run.value = r.run
     interactions.value = r.interactions
     context.value = r.context
+    if (previousComposer !== composerKey.value) {
+      taskFiles.set(previousComposer,composer.value?.filesSnapshot() ?? [])
+      taskQuotes.set(previousComposer,quoted.value)
+      if (activeTask.value && !requestedTask) {
+        const legacy = `hermes-yaoyao:composer:group:${auth.user?.id}:${active.value.id}`
+        const current = `hermes-yaoyao:composer:group:${composerKey.value}`
+        try {
+          const draft = localStorage.getItem(legacy)
+          if (draft && !localStorage.getItem(current)) { localStorage.setItem(current,draft);localStorage.removeItem(legacy) }
+        } catch { /* Leave the original draft intact when browser storage is unavailable. */ }
+      }
+      await nextTick()
+      const saved = taskFiles.get(composerKey.value)
+      if (saved?.length) await composer.value?.attachFiles(saved)
+      quoted.value = taskQuotes.get(composerKey.value) ?? null
+    }
     if (!append) older.value = r.messages.length === 100
     if (!append || atBottom) {
       await nextTick()
@@ -244,19 +275,55 @@ async function load(id = selected.value, append = false) {
       await markRead()
     }
   } catch (e) {
-    error.value = e instanceof Error ? e.message : '加载失败'
+    if (own === generation && requestedTask && e instanceof ApiError && e.status === 404) {
+      try {
+        const current = await apiRequest<{tasks:WorkspaceTask[]}>(`/api/app/conversations/${id}/tasks`)
+        const fallback = current.tasks[0]
+        if (own === generation && fallback && fallback.id !== requestedTask) {
+          await router.replace({query:{...route.query,taskId:fallback.id}})
+          return
+        }
+      } catch { /* A deleted conversation still reports its original error. */ }
+    }
+    if (own === generation) error.value = e instanceof Error ? e.message : '加载失败'
   } finally {
     if (own === generation) loading.value = false
   }
 }
 async function markRead() {
   const c = active.value
-  if (!c || c.readSeq >= c.lastSeq) return
-  await apiRequest(`/api/app/conversations/${c.id}/read`, {
+  const read = activeTask.value ?? c
+  if (!c || !read || read.readSeq >= read.lastSeq) return
+  await apiRequest(`/api/app/conversations/${c.id}/read${activeTask.value ? `?taskId=${activeTask.value.id}` : ''}`, {
     method: 'PUT',
-    body: { seq: c.lastSeq },
+    body: { seq: read.lastSeq },
   })
-  c.readSeq = c.lastSeq
+  read.readSeq = read.lastSeq
+}
+async function stopTask() {
+  if (!active.value || !activeTask.value) return
+  try {
+    await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/stop`,{method:'POST',body:{}})
+    await load(undefined,true)
+  } catch(cause) {error.value=cause instanceof Error?cause.message:'停止任务失败'}
+}
+async function resumeTask() {
+  if (!active.value || !activeTask.value) return
+  try {
+    await apiRequest(`/api/app/conversations/${active.value.id}/tasks/${activeTask.value.id}/resume`,{method:'POST',body:{requestId:createUuid()}})
+    await load(undefined,true)
+  } catch(cause) {error.value=cause instanceof Error?cause.message:'恢复任务失败'}
+}
+function openTaskLink(event: MouseEvent) {
+  if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
+  const anchor=(event.target as Element)?.closest?.('a[href]')
+  if (!anchor) return
+  try {
+    const url=new URL(anchor.getAttribute('href') || '',window.location.origin)
+    if(url.origin!==window.location.origin || !/^\/conversations\/[^/]+$/.test(url.pathname) || !url.searchParams.get('taskId'))return
+    event.preventDefault();event.stopPropagation()
+    void router.push(url.pathname+url.search)
+  } catch { /* Leave other links to their normal handler. */ }
 }
 async function poll() {
   try {
@@ -282,7 +349,6 @@ async function select(id: string) {
   files.value = []
   mentions.value = []
   text.value = ''
-  quoted.value = null
   preview.value = null
   mediaIndex.value = null
   pendingRequestId = undefined
@@ -420,7 +486,7 @@ async function send() {
   if (!c || busy.value || (!text.value.trim() && !files.value.length)) return
   busy.value = true
   error.value = ''
-  const fingerprint = JSON.stringify([c.id, text.value, mentions.value, files.value.map(f => f.id)])
+  const fingerprint = JSON.stringify([c.id, activeTask.value?.id, text.value, mentions.value, files.value.map(f => f.id)])
   if (!pendingRequestId || pendingFingerprint !== fingerprint) {
     pendingRequestId = createUuid()
     pendingFingerprint = fingerprint
@@ -430,6 +496,7 @@ async function send() {
       method: 'POST',
       body: {
         requestId: pendingRequestId,
+        ...(activeTask.value ? { taskId: activeTask.value.id } : {}),
         content: text.value,
         mentionIds: mentions.value,
         fileIds: files.value.map((f) => f.id),
@@ -499,10 +566,12 @@ async function respond(i: Interaction, answer: string) {
 async function loadOlder() {
   const c = active.value
   if (!c) return
+  const taskId = activeTask.value?.id, own = generation
   try {
     const r = await apiRequest<{ messages: Message[] }>(
-      `/api/app/conversations/${c.id}/messages?before=${messages.value[0]?.seq ?? 0}`,
+      `/api/app/conversations/${c.id}/messages?before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
     )
+    if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
     messages.value = [...r.messages, ...messages.value]
     older.value = r.messages.length === 100
   } catch (e) {
@@ -523,7 +592,13 @@ function rendered(m: Message) {
   )
 }
 
-watch(selected, () => void load())
+watch([selected,selectedTask], () => {
+  taskFiles.set(composerKey.value, composer.value?.filesSnapshot() ?? [])
+  taskQuotes.set(composerKey.value, quoted.value)
+  files.value = [];mentions.value = [];text.value = ''
+  pendingRequestId = undefined;uploadedSources = [];uploadedReferences = []
+  void load()
+})
 onMounted(async () => {
   void auth.refreshProfileAvatars().catch(() => undefined)
   try {
@@ -572,11 +647,11 @@ onBeforeUnmount(() => {
     <template #mobile-sidebar
       ><ConversationList :conversations="conversations" :agents="agents" :selected="selected" @select="select" @pin="action('pin', $event)" @archive="action('archive', $event)"
     /></template>
-    <section class="workspace-chat" aria-label="聊天">
+    <section class="workspace-chat" aria-label="聊天" @click.capture="openTaskLink">
       <MessageTimeline ref="timeline" :messages="uiMessages" :title="active?.name || 'Bot 模式'"
         :subtitle="active?.kind === 'group' ? `${members.length} 位成员` : ''"
         :loading="loading" :has-older="older && !!active" :connected="!error" :synced="!loading"
-        :show-tools="showThinking" :allow-branch="false" :thinking="!!active?.activeRunId"
+        :show-tools="showThinking" :allow-branch="false" :thinking="!!run && ['running','waiting','uncertain'].includes(run.status)"
         :agent-avatars="Object.fromEntries(agents.map(a => [a.id, a.avatar]))"
         :agent-states="Object.fromEntries(members.map(a => [a.id, workspaceAvatarState(active, a.id)]))"
         :mention-names="members.map(a => a.name)"
@@ -589,14 +664,19 @@ onBeforeUnmount(() => {
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-actions>
           <div v-if="active" class="header-actions">
+            <select v-if="active.kind === 'group' && tasks.length" class="task-picker" aria-label="当前任务" :value="activeTask?.id" @change="router.push({query:{...route.query,taskId:($event.target as HTMLSelectElement).value}})">
+              <option v-for="task in tasks" :key="task.id" :value="task.id">{{ task.title }}</option>
+            </select>
             <button class="icon-button" :aria-label="active.pinned ? '取消置顶' : '置顶聊天'" :title="active.pinned ? '取消置顶' : '置顶聊天'" @click="action('pin')"><AppIcon :name="active.pinned ? 'pin-off' : 'pin'" /></button>
             <button class="icon-button" aria-label="聊天设置" title="聊天设置" @click="openDialog(active.kind === 'direct' ? 'editAgent' : 'editGroup')"><AppIcon name="settings" /></button>
             <button class="icon-button" :aria-label="active.archived ? '恢复聊天' : '归档聊天'" :title="active.archived ? '恢复聊天' : '归档聊天'" @click="action('archive')"><AppIcon name="archive" /></button>
           </div>
         </template>
       </MessageTimeline>
+      <p v-if="run?.status === 'queued'" class="task-queue-status" role="status">正在等待可用 Agent</p>
+      <TaskPlan :goal="activeTask?.goal" :assignments="assignments" :agents="agents" @stop="stopTask" @resume="resumeTask" />
       <p v-if="error" class="error" role="alert">{{ error }}<button class="icon-button" @click="error = ''" aria-label="关闭错误"><AppIcon name="close" /></button></p>
-      <ComposerShell v-if="active" :key="active.id" ref="composer" mode="group" :draft-key="`${auth.user?.id}:${active.id}`"
+      <ComposerShell v-if="active" :key="composerKey" ref="composer" mode="group" :draft-key="composerKey"
         :disabled="active.archived || loading || active.id !== selected" :sending="busy" stop-while-running :streaming="!!active.activeRunId"
         :tool-trace-visible="showThinking" :reference="reference" :context-used="Number(context?.usedTokens || 0)" :context-limit="Number(context?.limitTokens || 0)"
         :mention-options="active.kind === 'group' ? members.map(a => ({id:a.id,label:a.name,insertText:`@${a.name} `})) : []"
@@ -765,6 +845,9 @@ onBeforeUnmount(() => {
   margin: 15px 0;
   font-size: 13px;
 }
+.task-picker { max-width:180px;min-height:36px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);padding:4px 8px;font:inherit }
+.task-queue-status{margin:0 20px 8px;font-size:13px;color:var(--text-muted)}
+@media(max-width:720px){.task-picker{max-width:100px}}
 .team-management-permission > span {
   display: flex;
   align-items: center;

+ 1 - 1
src/server/app.ts

@@ -205,7 +205,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspace = new WorkspaceStore(config.home)
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
-  const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner))
+  const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
   workspaceRuntime.onTeamCreated = (owner, team) => {
     try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
   }

+ 390 - 0
src/server/taskCoordinator.ts

@@ -0,0 +1,390 @@
+import { createHash, randomUUID } from 'node:crypto'
+import { z } from 'zod'
+import { HttpError } from './errors.js'
+import { parse, type WorkspaceStore } from './workspaceStore.js'
+import type { WorkspaceRuntime } from './workspaceRuntime.js'
+import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceRun as Run, WorkspaceTask as Task } from '../shared/workspace.js'
+import type { AgentGoal, AgentAssignment, TaskDelivery, TaskOrigin } from '../shared/agentTasks.js'
+
+const terminalRun = (status: string) => ['complete', 'failed', 'interrupted'].includes(status)
+const terminalGoal = (status: string) => ['complete', 'blocked', 'cancelled'].includes(status)
+const criteria = z.array(z.string().trim().min(1).max(1000)).max(12).default([])
+export const assignmentInput = z.object({
+  requestId: z.string().uuid(), goalId: z.string().uuid(), agentId: z.string().uuid(),
+  title: z.string().trim().min(1).max(100), brief: z.string().trim().min(1).max(16000),
+  acceptanceCriteria: criteria, dependsOn: z.array(z.string().uuid()).max(12).default([]),
+}).strict()
+export const assignmentReview = z.object({
+  requestId: z.string().uuid(), assignmentId: z.string().uuid(),
+  decision: z.enum(['accept', 'retry', 'blocked']), review: z.string().trim().min(1).max(4000),
+}).strict()
+export const assignmentUpdate = z.object({
+  requestId: z.string().uuid(), assignmentId: z.string().uuid(),
+  title: z.string().trim().min(1).max(100).optional(), brief: z.string().trim().min(1).max(16000).optional(),
+  agentId: z.string().uuid().optional(),
+  acceptanceCriteria: criteria.optional(), dependsOn: z.array(z.string().uuid()).max(12).optional(),
+}).strict()
+export const assignmentCancel = z.object({requestId:z.string().uuid(),assignmentId:z.string().uuid(),reason:z.string().trim().min(1).max(2000)}).strict()
+export const finishGoalInput = z.object({
+  requestId: z.string().uuid(), goalId: z.string().uuid(), status: z.enum(['complete', 'blocked', 'waiting']),
+  result: z.string().trim().min(1).max(16000), artifactIds: z.array(z.string().uuid()).max(24).default([]),
+  checks: z.array(z.object({criterion:z.number().int().min(0).max(11),passed:z.boolean(),evidence:z.string().trim().min(1).max(2000)}).strict()).max(12).default([]),
+}).strict()
+export const resumeGoalInput = z.object({requestId:z.string().uuid(),goalId:z.string().uuid()}).strict()
+
+/** Stable internal command identity; retries can never create a second wake. */
+function requestId(value: string): string {
+  const h = createHash('sha256').update(value).digest('hex')
+  return `${h.slice(0,8)}-${h.slice(8,12)}-5${h.slice(13,16)}-a${h.slice(17,20)}-${h.slice(20,32)}`
+}
+
+export class WorkspaceTaskCoordinator {
+  private pending = false
+  private closed = false
+  private unobserve: () => void
+  private timer: ReturnType<typeof setInterval>
+  constructor(readonly store: WorkspaceStore, readonly runtime: WorkspaceRuntime) {
+    this.unobserve = store.observe((_owner, event) => {
+      if (['run.changed', 'agent.changed', 'conversation.changed', 'task.deleted', 'assignment.changed', 'goal.changed'].includes(event.type)) this.wake()
+    })
+    this.timer = setInterval(() => this.wake(), 5000);this.timer.unref()
+    this.wake()
+  }
+  begin(owner: string, task: Task, coordinator: Agent, objective: string, origin: TaskOrigin, acceptanceCriteria: string[] = []): AgentGoal {
+    const old = this.store.get<AgentGoal>(owner, 'goal', task.id)
+    if (old) throw new HttpError(409, '这个任务已有目标,请继续原任务或新建任务', 'goal_exists')
+    const goal: AgentGoal = { id: task.id, conversationId: task.conversationId, coordinatorId: coordinator.id,
+      authorityRevision: coordinator.teamAuthorizationVersion ?? 0, objective, acceptanceCriteria: acceptanceCriteria.length ? acceptanceCriteria : ['对照原始请求核验交付结果'],
+      authorizationVersion: this.runtime.authorizationVersion(owner), activation: 1,
+      status: 'running', origin, artifactIds: [], revision: 1, automaticWakes: 0, createdAt: Date.now(), updatedAt: Date.now() }
+    this.saveGoal(owner, goal)
+    return goal
+  }
+  private saveGoal(owner: string, goal: AgentGoal) {
+    goal.updatedAt = Date.now()
+    this.store.put(owner, 'goal', goal.id, goal)
+    const task = this.store.get<Task>(owner, 'conversation-task', goal.id)
+    if (task) {
+      task.goal = goal
+      this.store.put(owner, 'conversation-task', task.id, task)
+      this.store.event(owner, 'task.changed', task, task.conversationId)
+    }
+    this.store.event(owner, 'goal.changed', goal, goal.conversationId)
+  }
+  private saveAssignment(owner: string, assignment: AgentAssignment) {
+    assignment.updatedAt = Date.now()
+    this.store.put(owner, 'assignment', assignment.id, assignment)
+    this.store.event(owner, 'assignment.changed', assignment, assignment.conversationId)
+  }
+  assignments(owner: string, goalId: string): AgentAssignment[] {
+    return this.store.list<AgentAssignment>(owner, 'assignment').filter(a => a.goalId === goalId)
+      .sort((a,b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
+  }
+  requireCoordinator(owner: string, goalId: string, actorId: string): AgentGoal {
+    const goal = this.store.require<AgentGoal>(owner, 'goal', goalId)
+    const team = this.store.require<Conversation>(owner, 'conversation', goal.conversationId)
+    if (!this.authorized(owner, goal) || goal.coordinatorId !== actorId || team.administratorId !== actorId)
+      throw new HttpError(403, '当前 Agent 无权管理这个任务', 'goal_forbidden')
+    return goal
+  }
+  private authorized(owner: string, goal: AgentGoal): boolean {
+    try {
+      const agent = this.store.require<Agent>(owner, 'agent', goal.coordinatorId)
+      const team = this.store.require<Conversation>(owner, 'conversation', goal.conversationId)
+      this.store.requireTask(owner, team.id, goal.id)
+      this.runtime.nodes.requireSource(owner, agent)
+      return this.runtime.userActive(owner) && !agent.archived && agent.canManageTeam === true
+        && (goal.authorizationVersion === undefined || goal.authorizationVersion === this.runtime.authorizationVersion(owner))
+        && (agent.teamAuthorizationVersion ?? 0) === goal.authorityRevision
+        && !team.archived && team.administratorId === agent.id && team.memberIds.includes(agent.id)
+    } catch { return false }
+  }
+  createAssignment(owner: string, actorId: string, input: unknown): AgentAssignment {
+    const { requestId: command, ...body } = parse(assignmentInput, input)
+    const goal = this.requireCoordinator(owner, body.goalId, actorId)
+    if (terminalGoal(goal.status)) throw new HttpError(409, '目标已结束', 'goal_terminal')
+    const team = this.store.require<Conversation>(owner, 'conversation', goal.conversationId)
+    const agent = this.store.require<Agent>(owner, 'agent', body.agentId)
+    this.runtime.nodes.requireSource(owner, agent)
+    if (!team.memberIds.includes(agent.id) || agent.archived || agent.id === actorId)
+      throw new HttpError(400, '请选择其他有效团队成员;管理员可以直接处理自己的工作。', 'invalid_assignment_agent')
+    const previous = this.assignments(owner, goal.id)
+    if (body.dependsOn.some(id => !previous.some(a => a.id === id))) throw new HttpError(400, '依赖必须属于同一目标中的已有子任务', 'invalid_assignment_dependency')
+    return this.store.command(owner, command, { actorId, operation: 'assignment.create', ...body }, () => {
+      if (previous.length >= 32) throw new HttpError(409, '每个目标最多 32 个子任务', 'assignment_limit')
+      const assignment: AgentAssignment = { ...body, id: randomUUID(), conversationId: goal.conversationId,
+        status: 'pending', attempt: 0, artifactIds: [], createdAt: Date.now(), updatedAt: Date.now() }
+      this.saveAssignment(owner, assignment)
+      goal.status = 'running';goal.revision++;this.saveGoal(owner, goal)
+      return assignment
+    })
+  }
+  reviewAssignment(owner: string, actorId: string, input: unknown): AgentAssignment {
+    const body = parse(assignmentReview, input)
+    const assignment = this.store.require<AgentAssignment>(owner, 'assignment', body.assignmentId)
+    const goal = this.requireCoordinator(owner, assignment.goalId, actorId)
+    return this.store.command(owner, body.requestId, { actorId, operation: 'assignment.review', ...body }, () => {
+      if (terminalGoal(goal.status) || !['review', 'failed', 'blocked'].includes(assignment.status))
+        throw new HttpError(409, '子任务尚未进入复核状态', 'assignment_not_reviewable')
+      if (body.decision === 'accept' && assignment.status !== 'review')
+        throw new HttpError(409, '失败的执行不能直接验收为完成', 'assignment_failed')
+      if (body.decision === 'retry' && assignment.attempt >= (assignment.maxAttempts ?? 3))
+        throw new HttpError(409, '此子任务已达到 3 次执行上限,请调整方案', 'assignment_retry_limit')
+      assignment.review = body.review
+      assignment.status = body.decision === 'accept' ? 'complete' : body.decision === 'retry' ? 'pending' : 'blocked'
+      if (body.decision === 'retry') { assignment.runId = undefined;assignment.result = undefined }
+      this.saveAssignment(owner, assignment)
+      goal.status = 'running';goal.revision++;this.saveGoal(owner, goal)
+      return assignment
+    })
+  }
+  updateAssignment(owner: string, actorId: string, input: unknown): AgentAssignment {
+    const {requestId:command,assignmentId,...patch}=parse(assignmentUpdate,input)
+    const assignment=this.store.require<AgentAssignment>(owner,'assignment',assignmentId)
+    const goal=this.requireCoordinator(owner,assignment.goalId,actorId)
+    return this.store.command(owner,command,{actorId,operation:'assignment.update',assignmentId,...patch},()=>{
+      if(terminalGoal(goal.status)||!['pending','failed','blocked'].includes(assignment.status))
+        throw new HttpError(409,'只能调整尚未运行或已失败的子任务','assignment_not_editable')
+      const all=this.assignments(owner,goal.id), dependencies=patch.dependsOn??assignment.dependsOn
+      if(dependencies.some(id=>id===assignment.id||!all.some(a=>a.id===id)))throw new HttpError(400,'子任务依赖无效','invalid_assignment_dependency')
+      const reaches=(id:string,seen=new Set<string>()):boolean=>{
+        if(id===assignment.id)return true
+        if(seen.has(id))return false
+        seen.add(id);return (all.find(a=>a.id===id)?.dependsOn??[]).some(next=>reaches(next,seen))
+      }
+      if(dependencies.some(id=>reaches(id)))throw new HttpError(400,'子任务依赖不能形成循环','assignment_dependency_cycle')
+      if(patch.agentId && patch.agentId!==assignment.agentId) {
+        const target=this.store.require<Agent>(owner,'agent',patch.agentId)
+        const team=this.store.require<Conversation>(owner,'conversation',goal.conversationId)
+        this.runtime.nodes.requireSource(owner,target)
+        if(target.archived||target.id===actorId||!team.memberIds.includes(target.id))throw new HttpError(400,'请选择有效团队成员','invalid_assignment_agent')
+        assignment.status='pending';assignment.runId=undefined;assignment.result=undefined
+      }
+      Object.assign(assignment,patch);this.saveAssignment(owner,assignment)
+      goal.revision++;this.saveGoal(owner,goal);return assignment
+    })
+  }
+  cancelAssignment(owner:string,actorId:string,input:unknown):AgentAssignment {
+    const body=parse(assignmentCancel,input),assignment=this.store.require<AgentAssignment>(owner,'assignment',body.assignmentId)
+    const goal=this.requireCoordinator(owner,assignment.goalId,actorId)
+    return this.store.command(owner,body.requestId,{actorId,operation:'assignment.cancel',...body},()=>{
+      if(terminalGoal(goal.status)||assignment.status==='complete')throw new HttpError(409,'已完成的工作不能通过取消操作撤销','assignment_not_cancellable')
+      const run=assignment.runId?this.store.get<Run>(owner,'run',assignment.runId):undefined
+      assignment.review=body.reason;assignment.status=run&&!terminalRun(run.status)?'cancelling':'cancelled'
+      this.saveAssignment(owner,assignment)
+      return assignment
+    })
+  }
+  finish(owner: string, actorId: string, input: unknown, reportingRunId?: string): AgentGoal {
+    const body = parse(finishGoalInput, input), goal = this.requireCoordinator(owner, body.goalId, actorId)
+    return this.store.command(owner, body.requestId, { actorId, operation: 'goal.finish', ...body }, () => {
+      if (terminalGoal(goal.status)) throw new HttpError(409, '目标已结束', 'goal_terminal')
+      const assignments=this.assignments(owner,goal.id)
+      if (body.status === 'complete' && (assignments.some(a => !['complete','cancelled'].includes(a.status)) || (assignments.length>0&&!assignments.some(a=>a.status==='complete'))))
+        throw new HttpError(409, '还有未验收的子任务,不能宣布目标完成', 'goal_incomplete')
+      if (body.status === 'complete' && goal.acceptanceCriteria.some((_criterion,index)=>!body.checks.some(check=>check.criterion===index&&check.passed&&check.evidence.trim())))
+        throw new HttpError(409,'需要逐项提供验收依据,才能宣布目标完成','goal_checks_missing')
+      if (body.status === 'complete' && this.store.list<Run>(owner,'run').some(r=>r.conversationId===goal.conversationId&&r.conversationTaskId===goal.id&&r.id!==reportingRunId&&!terminalRun(r.status)))
+        throw new HttpError(409,'任务仍有运行中的执行,请先等待或停止','goal_still_running')
+      for (const id of body.artifactIds) {
+        const file = this.store.require<{ conversationId?: string }>(owner, 'file', id)
+        if (file.conversationId && file.conversationId !== goal.conversationId)
+          throw new HttpError(403, '产物不属于这个团队', 'goal_artifact_forbidden')
+      }
+      goal.status = body.status;goal.result = body.result;goal.artifactIds = body.artifactIds;goal.revision++
+      goal.reportingRunId = reportingRunId
+      goal.checks = body.checks
+      this.saveGoal(owner, goal)
+      return goal
+    })
+  }
+  resume(owner: string, actorId: string, input: unknown, origin: TaskOrigin): AgentGoal {
+    const body=parse(resumeGoalInput,input),goal=this.store.require<AgentGoal>(owner,'goal',body.goalId)
+    const agent=this.store.require<Agent>(owner,'agent',actorId),team=this.store.require<Conversation>(owner,'conversation',goal.conversationId)
+    this.runtime.nodes.requireSource(owner,agent)
+    const source=this.store.require<Run>(owner,'run',origin.runId)
+    const message=this.store.require<{role:string}>(owner,'message',source.messageId)
+    if (!this.runtime.userActive(owner)||!agent.canManageTeam||agent.archived||team.archived||goal.coordinatorId!==actorId||team.administratorId!==actorId||source.triggerKind||message.role!=='user')
+      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(!['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')
+      for(const assignment of this.assignments(owner,goal.id)) {
+        assignment.maxAttempts=assignment.attempt+3
+        if(assignment.status==='cancelled') {assignment.status='pending';assignment.runId=undefined}
+        this.saveAssignment(owner,assignment)
+      }
+      Object.assign(goal,{status:'running',origin,authorityRevision:agent.teamAuthorizationVersion??0,
+        authorizationVersion:this.runtime.authorizationVersion(owner),activation:(goal.activation??0)+1,
+        automaticWakes:0,reviewRunId:source.id,lastReviewedBatch:undefined,reportingRunId:undefined,result:undefined,checks:undefined})
+      goal.revision++;this.saveGoal(owner,goal);return goal
+    })
+  }
+  cancel(owner: string, goalId: string, reason = '用户已停止任务'): void {
+    const goal = this.store.get<AgentGoal>(owner, 'goal', goalId)
+    if (!goal || terminalGoal(goal.status) || goal.status==='cancelling') return
+    this.store.atomic(() => {
+      goal.status = 'cancelling';goal.result = reason;goal.revision++
+      for (const assignment of this.assignments(owner, goal.id)) {
+        if (!['complete', 'cancelled'].includes(assignment.status)) {
+          const run=assignment.runId?this.store.get<Run>(owner,'run',assignment.runId):undefined
+          assignment.status = run&&!terminalRun(run.status)?'cancelling':'cancelled';this.saveAssignment(owner, assignment)
+        }
+      }
+      this.saveGoal(owner, goal)
+    })
+  }
+  async cancelOrigin(owner: string, conversationId: string, taskId?: string, visited = new Set<string>()): Promise<void> {
+    const pending:Promise<void>[]=[]
+    for (const goal of this.store.list<AgentGoal>(owner, 'goal')) {
+      if (terminalGoal(goal.status)) continue
+      if (goal.origin.conversationId === conversationId && (!taskId || goal.origin.conversationTaskId === taskId)) {
+        this.cancel(owner, goal.id)
+        pending.push(this.runtime.stopTask(owner, goal.conversationId, goal.id, visited))
+      }
+    }
+    await Promise.all(pending)
+  }
+  wake(): void {
+    if (this.closed || this.pending) return
+    this.pending = true
+    queueMicrotask(() => {
+      this.pending = false
+      if (this.closed) return
+      for (const owner of this.store.owners()) for (const goal of this.store.list<AgentGoal>(owner, 'goal')) {
+        try { this.reconcile(owner, goal) } catch { /* The next committed event retries durable state. */ }
+      }
+    })
+  }
+  private reconcile(owner: string, goal: AgentGoal): void {
+    if(goal.status==='cancelling') {
+      const all=this.store.list<AgentGoal>(owner,'goal'), owned=new Set([goal.id])
+      for(let changed=true;changed;) {
+        changed=false
+        for(const child of all) if(child.origin.conversationTaskId&&owned.has(child.origin.conversationTaskId)&&!owned.has(child.id)) {owned.add(child.id);changed=true}
+      }
+      const active=this.store.list<Run>(owner,'run').filter(r=>r.conversationTaskId&&owned.has(r.conversationTaskId)&&!terminalRun(r.status))
+      if(!active.length) {
+        for(const assignment of this.assignments(owner,goal.id)) if(assignment.status==='cancelling'){assignment.status='cancelled';this.saveAssignment(owner,assignment)}
+        goal.status='cancelled';goal.revision++;this.saveGoal(owner,goal)
+      } else if(active.some(r=>!r.stopRequested))void this.runtime.stopTask(owner,goal.conversationId,goal.id).catch(()=>{})
+      return
+    }
+    if (!this.authorized(owner, goal) && !terminalGoal(goal.status)) {
+      this.cancel(owner, goal.id, '任务授权已失效')
+      void this.runtime.stopTask(owner, goal.conversationId, goal.id).catch(() => {})
+      return
+    }
+    if (goal.status === 'cancelled') return
+    if (goal.status === 'blocked') {
+      for (const assignment of this.assignments(owner,goal.id)) if (['pending','running','cancelling'].includes(assignment.status)) {
+        const run=assignment.runId?this.store.get<Run>(owner,'run',assignment.runId):undefined
+        const next=run&&!terminalRun(run.status)?'cancelling':'cancelled'
+        if(assignment.status!==next){assignment.status=next;this.saveAssignment(owner,assignment)}
+        if(run&&!terminalRun(run.status)&&!run.stopRequested)void this.runtime.stop(owner,run.id).catch(()=>{})
+      }
+    }
+    if (terminalGoal(goal.status) || goal.status === 'waiting') { this.deliver(owner, goal, 'terminal', String(goal.revision));return }
+    const assignments = this.assignments(owner, goal.id)
+    for (const assignment of assignments) {
+      if(assignment.status==='cancelling') {
+        const run=assignment.runId?this.store.get<Run>(owner,'run',assignment.runId):undefined
+        if(!run||terminalRun(run.status)){assignment.status='cancelled';this.saveAssignment(owner,assignment)}
+        else void this.runtime.stop(owner,run.id).catch(()=>{})
+        continue
+      }
+      if (assignment.status === 'running' && assignment.runId) {
+        const run = this.store.get<Run>(owner, 'run', assignment.runId)
+        if (run && terminalRun(run.status)) {
+          const messages = this.store.messages(owner, goal.conversationId, Number.MAX_SAFE_INTEGER, 100, false, goal.id)
+            .filter(m => m.runId === run.id && m.role === 'assistant')
+          assignment.result = messages.map(m => m.content).join('\n\n').slice(-16000) || run.error || '未产生有效结果'
+          assignment.artifactIds = [...new Set(messages.flatMap(m => m.attachments.map(f => f.id)))]
+          assignment.status = run.status === 'complete' && messages.some(m => m.content.trim() || m.attachments.length) ? 'review' : 'failed'
+          this.saveAssignment(owner, assignment)
+        }
+      }
+      if (assignment.status === 'pending') {
+        const dependencies = assignments.filter(a => assignment.dependsOn.includes(a.id))
+        if (dependencies.some(a => ['failed', 'blocked', 'cancelled'].includes(a.status))) continue
+        if (dependencies.some(a => a.status !== 'complete')) continue
+        const target = this.store.get<Agent>(owner, 'agent', assignment.agentId)
+        const team = this.store.require<Conversation>(owner, 'conversation', goal.conversationId)
+        if (!target || target.archived || !team.memberIds.includes(target.id)) {
+          assignment.status = 'blocked';assignment.result = '负责成员已不可用';this.saveAssignment(owner, assignment);continue
+        }
+        try { this.runtime.nodes.requireSource(owner, target) }
+        catch { assignment.status = 'blocked';assignment.result = '负责成员的基础 Profile 已撤权';this.saveAssignment(owner, assignment);continue }
+        this.store.atomic(() => {
+          const content = `由管理员分派的子任务:${assignment.title}\n${assignment.brief}\n验收要求:${assignment.acceptanceCriteria.join(';') || '提供实际结果及依据'}\n${assignment.review ? `上次复核意见:${assignment.review}\n` : ''}${dependencies.map(a => `依赖结果 ${a.title}:${a.result?.slice(0,1500)}`).join('\n')}\n完成后提交结果,由管理员复核,不要自行转交。`
+          const run = this.runtime.dispatch(owner, goal.conversationId,
+            { requestId: requestId(`assignment:${assignment.id}:${assignment.attempt}`), taskId: goal.id, content },
+            { agentId: goal.coordinatorId, assignmentId: assignment.id, targetAgentId: assignment.agentId, kind: 'assignment' })
+          assignment.runId = run.id;assignment.attempt++;assignment.status = 'running';this.saveAssignment(owner, assignment)
+        })
+      }
+    }
+    const goalRuns = this.store.list<Run>(owner, 'run').filter(r => r.conversationId === goal.conversationId && r.conversationTaskId === goal.id)
+    if (!assignments.length && !goalRuns.length) return
+    const activeRuns = goalRuns.some(r => !terminalRun(r.status))
+    if (activeRuns) return
+    const reviewing = goal.reviewRunId && this.store.get<Run>(owner, 'run', goal.reviewRunId)
+    if (reviewing && !terminalRun(reviewing.status)) return
+    const batch = assignments.map(a => `${a.id}:${a.status}:${a.attempt}`).join('|') || 'initial'
+    if (goal.lastReviewedBatch === batch) {
+      if (goal.status === 'review') this.deliver(owner, goal, 'review', batch)
+      return
+    }
+    if (!assignments.length || assignments.some(a => ['review', 'failed', 'blocked'].includes(a.status)) || assignments.every(a => ['complete','cancelled'].includes(a.status))) {
+      goal.status = 'review';goal.lastReviewedBatch = batch;goal.revision++;this.saveGoal(owner, goal)
+      this.deliver(owner, goal, 'review', batch)
+    }
+  }
+  private deliver(owner: string, goal: AgentGoal, kind: TaskDelivery['kind'], batch: string): void {
+    const id = requestId(`delivery:${goal.id}:${goal.activation??0}:${kind}:${batch}`)
+    let delivery = this.store.get<TaskDelivery>(owner, 'task-delivery', id)
+    if (delivery?.status === 'delivered' || delivery?.status === 'suppressed') return
+    const source = this.store.get<Conversation>(owner, 'conversation', goal.origin.conversationId)
+    const sourceRun = this.store.get<Run>(owner, 'run', goal.origin.runId)
+    const canWake = this.authorized(owner, goal) && source && !source.archived && source.memberIds.includes(goal.origin.agentId)
+      && sourceRun && !sourceRun.stopRequested && sourceRun.status !== 'interrupted'
+      && (!goal.origin.conversationTaskId || this.store.get(owner, 'conversation-task', goal.origin.conversationTaskId))
+    delivery ??= { id, goalId: goal.id, kind, status: 'pending', targetConversationId: goal.origin.conversationId,
+      targetTaskId: goal.origin.conversationTaskId, createdAt: Date.now() }
+    if (kind === 'terminal' && goal.reportingRunId) {
+      const reported = this.store.get<Run>(owner, 'run', goal.reportingRunId)
+      if (reported?.conversationId === goal.origin.conversationId && reported.conversationTaskId === goal.origin.conversationTaskId) {
+        if (!terminalRun(reported.status)) return
+        if (reported.status === 'complete' && this.store.messages(owner, reported.conversationId, Number.MAX_SAFE_INTEGER, 20, false, reported.conversationTaskId)
+          .some(m => m.runId === reported.id && m.role === 'assistant' && m.content.trim())) {
+          delivery.status = 'delivered';delivery.runId = reported.id;this.store.put(owner, 'task-delivery', id, delivery);return
+        }
+      }
+    }
+    if (!canWake || goal.automaticWakes >= 8) {
+      delivery.status = 'suppressed';this.store.put(owner, 'task-delivery', id, delivery)
+      return
+    }
+    const assignments = this.assignments(owner, goal.id)
+    const taskTitle = this.store.get<Task>(owner,'conversation-task',goal.id)?.title || goal.objective.slice(0,100)
+    const teamName = this.store.get<Conversation>(owner,'conversation',goal.conversationId)?.name || '团队'
+    const statusLabel = {complete:'已完成',blocked:'遇到阻碍',waiting:'需要补充信息',review:'待复核',running:'进行中',cancelling:'正在停止',cancelled:'已停止'}[goal.status]
+    const text = kind === 'terminal'
+      ? `「${teamName}」的任务「${taskTitle}」${statusLabel}。\n${goal.result || ''}\n[打开任务](/conversations/${goal.conversationId}?taskId=${goal.id})`
+      : `「${teamName}」的任务「${taskTitle}」已有执行结果,等待管理员复核。\n[打开任务](/conversations/${goal.conversationId}?taskId=${goal.id})`
+    const instruction = `本条来自系统任务投递。团队 ID:${goal.conversationId},目标/任务 ID:${goal.id}。目标:${goal.objective.slice(0,4000)}\n${assignments.slice(-12).map(a=>`${a.title}(${a.id}):${a.status}\n${a.result?.slice(0,1000)||''}`).join('\n\n')}\n${kind==='terminal'?'向用户汇报实际结果,不要重复启动相同任务。':'请使用 workspace_get_team_task 读取完整状态,使用 workspace_review_assignment 复核成员结果,必要时调整方案;满足验收条件后使用 workspace_finish_team_task 记录完成。'}`
+    this.store.atomic(() => {
+      this.store.put(owner, 'task-delivery', id, delivery)
+      const run = this.runtime.dispatch(owner, source!.id,
+        { requestId: id, taskId: goal.origin.conversationTaskId, content: text },
+        { agentId: goal.coordinatorId, kind: kind === 'review' ? 'task_review' : 'task_result', instruction,
+          taskReference:{conversationId:goal.conversationId,taskId:goal.id} })
+      delivery!.runId = run.id;delivery!.status = 'delivered';this.store.put(owner, 'task-delivery', id, delivery)
+      goal.automaticWakes++;this.saveGoal(owner, goal)
+      if (kind === 'review') { goal.reviewRunId = run.id;this.saveGoal(owner, goal) }
+    })
+  }
+  close(): void { this.closed = true;clearInterval(this.timer);this.unobserve() }
+}

+ 33 - 0
src/server/workspaceRoutes.ts

@@ -1,4 +1,5 @@
 import { readServerIdentity } from './serverIdentity.js'
+import { randomUUID } from 'node:crypto'
 import Router from '@koa/router'
 import type Koa from 'koa'
 import { createReadStream, statSync } from 'node:fs'
@@ -91,19 +92,23 @@ export function workspaceRouter(
   router.post('/api/app/agents', async (ctx) => {
     const user = owner(ctx),
       input = parse(agentInput, body(ctx))
+    const authorization = auth.pushAuthorizationVersion(user)
     nodes.requireSource(user, input)
     const sources = await nodes.sources(user)
     if (!sources.sources.some((s) => s.nodeId === input.nodeId && s.profile === input.profile))
       throw new HttpError(409, '基础 Agent 当前不可用', 'source_unavailable')
     nodes.requireSource(user, input)
     if (input.canManageTeam) await runtime.teamTools.requireAvailable(user, input)
+    if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
     ctx.body = { agent: store.agentSummary(store.createAgent(user, input)) }
     ctx.status = 201
   })
   router.patch('/api/app/agents/:id', async (ctx) => {
     const user = owner(ctx), agent = store.require<WorkspaceAgent>(user, 'agent', ctx.params.id), input = body(ctx)
+    const authorization = auth.pushAuthorizationVersion(user)
     nodes.requireSource(user, agent)
     if (input.canManageTeam === true && agent.canManageTeam !== true) await runtime.teamTools.requireAvailable(user, agent)
+    if (auth.pushAuthorizationVersion(user) !== authorization) throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
     nodes.requireSource(user, agent)
     ctx.body = { agent: store.agentSummary(store.updateAgent(user, ctx.params.id, input)) }
   })
@@ -141,6 +146,33 @@ export function workspaceRouter(
     store.resolveTask(user, ctx.params.id)
     ctx.body = { tasks: store.tasks(user, ctx.params.id) }
   })
+  router.get('/api/app/conversations/:id/tasks/:taskId/plan', (ctx) => {
+    const user = owner(ctx)
+    store.requireTask(user, ctx.params.id, ctx.params.taskId)
+    ctx.body = { goal: store.get(user, 'goal', ctx.params.taskId) ?? null,
+      assignments: runtime.tasks.assignments(user, ctx.params.taskId) }
+  })
+  router.post('/api/app/conversations/:id/tasks/:taskId/stop', async (ctx) => {
+    const user=owner(ctx)
+    store.requireTask(user,ctx.params.id,ctx.params.taskId)
+    await runtime.stopTask(user,ctx.params.id,ctx.params.taskId)
+    ctx.body={ok:true}
+  })
+  router.post('/api/app/conversations/:id/tasks/:taskId/resume', async (ctx) => {
+    const user=owner(ctx), input=parse(z.object({requestId:z.string().uuid()}).strict(),body(ctx))
+    const version=auth.pushAuthorizationVersion(user)
+    store.requireTask(user,ctx.params.id,ctx.params.taskId)
+    const goal=store.require<import('../shared/agentTasks.js').AgentGoal>(user,'goal',ctx.params.taskId)
+    const agent=store.require<WorkspaceAgent>(user,'agent',goal.coordinatorId)
+    await runtime.teamTools.requireAvailable(user,agent)
+    if(auth.pushAuthorizationVersion(user)!==version)throw new HttpError(401,'账号授权已变化,请重新登录','session_revoked')
+    ctx.body=store.command(user,input.requestId,{operation:'goal.resume-user',goalId:goal.id},()=>{
+      const run=runtime.send(user,ctx.params.id,{requestId:randomUUID(),taskId:goal.id,content:'继续处理当前目标,请先核对已有工作,避免重复执行。'})
+      const updated=runtime.tasks.resume(user,agent.id,{requestId:randomUUID(),goalId:goal.id},
+        {conversationId:ctx.params.id,conversationTaskId:goal.id,runId:run.id,agentId:agent.id})
+      return {goal:updated,run}
+    })
+  })
   router.post('/api/app/conversations/:id/tasks', (ctx) => {
     const task = store.createTask(owner(ctx), ctx.params.id, body(ctx))
     ctx.status = 201
@@ -170,6 +202,7 @@ export function workspaceRouter(
       conversation: store.conversationSummary(user, conversation),
       task: task ?? null,
       tasks,
+      assignments: task ? runtime.tasks.assignments(user, task.id) : [],
       messages: store.messages(user, conversation.id, Number.MAX_SAFE_INTEGER, 100, false, task?.id),
       hiddenMessageIds: store.hiddenMessageIds(user, conversation.id, task?.id),
       run: activeRunId

+ 52 - 4
src/server/workspaceRuntime.ts

@@ -18,6 +18,7 @@ import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK } from './worksp
 import { mentionedAgents } from './workspaceMentions.js'
 import { WorkspaceTeamTools, TEAM_TOOL_RULES } from './workspaceTeamTools.js'
 import { createWorkspaceToolLease, type WorkspaceToolLease } from './workspaceToolLease.js'
+import { WorkspaceTaskCoordinator } from './taskCoordinator.js'
 export { mentionedAgents } from './workspaceMentions.js'
 type Run = WorkspaceRun
 
@@ -57,16 +58,32 @@ export const sendInput = z
 export class WorkspaceRuntime extends WorkspaceScheduler {
   private live = new Map<string, LiveTurn>()
   readonly teamTools: WorkspaceTeamTools
+  readonly tasks: WorkspaceTaskCoordinator
   onTeamCreated: (owner: string, team: Conversation) => void = () => {}
-  constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true) {
+  constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true,
+    readonly authorizationVersion: (owner: string) => number = () => 0) {
     super(store, userActive)
     this.teamTools = new WorkspaceTeamTools(store, nodes, this)
+    this.tasks = new WorkspaceTaskCoordinator(store, this)
   }
   send(owner: string, conversationId: string, input: unknown): Run {
+    return this.submit(owner, conversationId, input)
+  }
+  /** Server-owned provenance. This is never exposed as a model-supplied identity. */
+  dispatch(owner: string, conversationId: string, input: unknown, source: {
+    agentId: string; assignmentId?: string; targetAgentId?: string; kind: NonNullable<Run['triggerKind']>; instruction?: string; taskReference?: {conversationId:string;taskId:string}
+  }): Run {
+    this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', source.agentId))
+    return this.submit(owner, conversationId, input, source)
+  }
+  private submit(owner: string, conversationId: string, input: unknown, source?: {
+    agentId: string; assignmentId?: string; targetAgentId?: string; kind: NonNullable<Run['triggerKind']>; instruction?: string; taskReference?: {conversationId:string;taskId:string}
+  }): Run {
+    if (!this.userActive(owner)) throw new HttpError(403,'账号授权已失效','account_inactive')
     const body = parse(sendInput, input)
     for (const id of this.store.require<Conversation>(owner, 'conversation', conversationId).memberIds)
       this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', id))
-    const result = this.store.command(owner, body.requestId, { conversationId, ...body }, () => {
+    const result = this.store.command(owner, body.requestId, { conversationId, ...body, ...(source ? { source } : {}) }, () => {
       const c = this.store.require<Conversation>(owner, 'conversation', conversationId)
       if (c.archived) throw new HttpError(409, '聊天已归档', 'conversation_archived')
       const conversationTask = this.store.resolveTask(owner, conversationId, body.taskId),
@@ -92,7 +109,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         conversationId,
         conversationTaskId,
         seq: 0,
-        role: 'user',
+        role: source ? 'system' : 'user',
+        ...(source?.taskReference ? {taskReference:source.taskReference} : {}),
+        ...(source ? { agentId: source.agentId, agentName: this.store.require<Agent>(owner, 'agent', source.agentId).name } : {}),
         content: body.content.trim(),
         reasoning: '',
         runId,
@@ -129,6 +148,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         round: 0,
         createdAt: now,
         updatedAt: now,
+        authorizationVersion: this.authorizationVersion(owner),
+        ...(source ? { assignmentId: source.assignmentId, targetAgentId: source.targetAgentId, triggerKind: source.kind, internalInstruction: source.instruction } : {}),
       }
       this.admit(owner, run, c, message)
       this.uploads.markReferenced(body.fileIds, owner)
@@ -144,6 +165,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     run: Work,
     recovering = false,
   ): Promise<Message> {
+    this.requireAuthorization(owner, run.runId)
     this.nodes.requireSource(owner, agent)
     if (agent.remoteAgentId) agent = await this.nodes.refreshRemoteAgent(owner,agent)
     const key = this.bindingKey(c.id, run.conversationTaskId, agent.id),
@@ -383,6 +405,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     }
     try {
       await gateway.connect()
+      this.requireAuthorization(owner, run.runId)
       this.nodes.requireSource(owner, agent)
       // Paired Web nodes enforce their own profile config at the destination.
       const cwd = recovering || target.pairedToken ? undefined
@@ -478,7 +501,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
         this.nodes.requireSource(owner, agent)
         // Only explicit grants mount the bridge; ordinary Bot turns never query plugins.
-        if (agent.canManageTeam === true) {
+        if (agent.canManageTeam === true && !this.store.require<Run>(owner, 'run', run.runId).assignmentId) {
           const granted = this.getWork(owner, run.id)
           granted.teamManagementRevision = agent.revision
           this.saveWork(owner, granted)
@@ -498,6 +521,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const trigger = this.store.require<Message>(owner, 'message', run.messageId)
         const attachmentRefs: string[] = []
         for (const file of trigger.attachments) {
+          this.requireAuthorization(owner, run.runId)
           const record = this.uploads.records([file.id], owner)[0]!
           const bytes = readFileSync(record.path).toString('base64')
           const attached = record.mimeType.startsWith('image/')
@@ -514,6 +538,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           if (attached.ref_text) attachmentRefs.push(String(attached.ref_text))
         }
         const members = run.turnConfiguration!.members
+        const goal = run.conversationTaskId ? this.store.get<import('../shared/agentTasks.js').AgentGoal>(owner, 'goal', run.conversationTaskId) : undefined
+        const assignmentId = this.store.require<Run>(owner, 'run', run.runId).assignmentId
         const rules = [
           `你是 ${agent.name}。以下是用户为这个独立 Agent 配置的角色与规则(版本 ${agent.revision}):\n${agent.remoteAgentId ? '配置由远端 Agent 管理。' : agent.instructions}`,
           c.kind === 'group' && Object.keys(c.memberRoles ?? {}).length
@@ -528,6 +554,8 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           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 || '',
+          goal ? `当前团队目标 ID:${goal.id}。目标:${goal.objective.slice(0,8000)}\n验收要求:${goal.acceptanceCriteria.join(';')}\n${assignmentId ? `你在执行子任务 ${assignmentId},请完成分派并提交结果,不要扩大团队或再次委派。` : '优先使用 workspace_assign_task 进行有依赖和验收要求的结构化分派;成员结果需要通过 workspace_review_assignment 复核。最终使用 workspace_finish_team_task 记录完成、受阻或等待用户。不要将一次模型回复结束当作目标完成。'}` : '',
           toolLease ? TEAM_TOOL_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]
@@ -540,6 +568,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           throw new Error('执行前成员已移除')
         }
         this.nodes.requireSource(owner, agent)
+        this.requireAuthorization(owner, run.runId)
         if (admission.cancelRequested || admission.status === 'interrupted') throw new Error('运行已停止')
         admission.contextThroughSeq = run.contextThroughSeq ?? run.triggerSeq
         admission.submitted = true
@@ -564,6 +593,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       return await completion
     }
   }
+  private requireAuthorization(owner: string, runId: string): void {
+    const run=this.store.require<Run>(owner,'run',runId)
+    if(!this.userActive(owner)||(run.authorizationVersion!==undefined&&run.authorizationVersion!==this.authorizationVersion(owner)))
+      throw new HttpError(403,'本轮账号授权已失效','run_authorization_revoked')
+  }
   private contextText(owner: string, c: Conversation, agent: Agent, work: Work, after: number): string {
     const rootTrigger = this.store.require<Message>(owner, 'message', work.messageId)
     const roots = new Map(this.store.list<Run>(owner, 'run').filter(r => r.conversationId === c.id && r.conversationTaskId === work.conversationTaskId).map(r => [r.id, this.store.get<Message>(owner, 'message', r.messageId)?.seq ?? 0]))
@@ -602,6 +636,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   async respond(owner: string, id: string, answer: string): Promise<void> {
     type Reply = WorkspaceInteraction & { answer?: string; responseState?: 'sending' | 'uncertain' | 'sent' }
     const interaction = this.store.require<Reply>(owner, 'interaction', id)
+    this.requireAuthorization(owner,interaction.runId)
     this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', interaction.agentId))
     if (interaction.resolved) {
       if (interaction.answer && interaction.answer !== answer) throw new HttpError(409, '该请求已使用其他答复完成', 'interaction_answer_conflict')
@@ -664,7 +699,20 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     }
     this.resolveInteractions(owner, work.id)
   }
+  override async stopTask(owner: string, conversationId: string, taskId: string, visited = new Set<string>()): Promise<void> {
+    const key = `${owner}:${taskId}`
+    if (visited.has(key)) return
+    visited.add(key)
+    this.tasks.cancel(owner, taskId)
+    await Promise.all([super.stopTask(owner, conversationId, taskId), this.tasks.cancelOrigin(owner, conversationId, taskId, visited)])
+  }
+  override async stopConversation(owner: string, conversationId: string): Promise<void> {
+    for (const task of this.store.list<import('../shared/workspace.js').WorkspaceTask>(owner, 'conversation-task'))
+      if (task.conversationId === conversationId) this.tasks.cancel(owner, task.id)
+    await Promise.all([super.stopConversation(owner, conversationId), this.tasks.cancelOrigin(owner, conversationId)])
+  }
   close(): void {
+    this.tasks.close()
     this.closeScheduler()
     for (const live of [...this.live.values()]) live.done(new Error('服务关闭'))
     this.live.clear()

+ 17 - 5
src/server/workspaceScheduler.ts

@@ -113,10 +113,10 @@ export abstract class WorkspaceScheduler {
   }
   protected admit(owner: string, root: Run, c: Conversation, trigger: Message): void {
     this.store.put(owner, 'run', root.id, root)
-    const ids = c.kind === 'direct' || c.mode === 'host' ? [c.administratorId]
+    const ids = root.assignmentId && root.targetAgentId ? [root.targetAgentId] : c.kind === 'direct' || c.mode === 'host' ? [c.administratorId]
       : root.mentionIds.length ? root.mentionIds : [...new Set([c.administratorId, ...c.autoReplyIds])]
     for (const id of ids) this.enqueue(owner, root, id, 0, root.id, trigger.seq,
-      c.kind === 'direct' ? 'mentioned' : c.mode === 'host' || !root.mentionIds.length ? 'automatic' : 'mentioned', c.kind === 'group' && id === c.administratorId && (c.mode === 'host' || !root.mentionIds.length))
+      c.kind === 'direct' || root.assignmentId ? 'mentioned' : c.mode === 'host' || !root.mentionIds.length ? 'automatic' : 'mentioned', !!root.assignmentId || c.kind === 'group' && id === c.administratorId && (c.mode === 'host' || !root.mentionIds.length))
     this.updateRoot(owner, root.id)
   }
   start(): void { this.wake() }
@@ -159,7 +159,7 @@ export abstract class WorkspaceScheduler {
       const root = this.store.require<Run>(owner, 'run', work.runId), c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
       work.planned = true
       this.store.put(owner, 'turn', work.id, work)
-      if (root.stopRequested || c.archived || c.kind === 'direct') { this.updateRoot(owner, root.id); return }
+      if (root.stopRequested || c.archived || c.kind === 'direct' || root.assignmentId) { this.updateRoot(owner, root.id); return }
       const config = work.turnConfiguration ?? { mode: c.mode, administratorId: c.administratorId, members: c.memberIds.map(id => this.store.require<Agent>(owner, 'agent', id)) }
       const message = work.currentMessageId ? this.store.get<Message>(owner, 'message', work.currentMessageId) : undefined
       if (config.mode === 'host' && work.agentId !== config.administratorId) {
@@ -196,6 +196,13 @@ export abstract class WorkspaceScheduler {
       for (const { owner, work } of all) if (terminal(work.status) && !work.planned && !this.executing.has(work.id)) this.plan(owner, work.id)
       all = this.store.owners().flatMap(owner => this.pendingWorks(owner).map(work => ({ owner, work })))
       all.sort((a, b) => {
+        const priority = (entry: typeof a) => {
+          if (Date.now() - entry.work.createdAt > 60_000) return 0
+          const run = this.store.get<Run>(entry.owner, 'run', entry.work.runId)
+          return run?.triggerKind === 'assignment' ? 2 : run?.triggerKind ? 1 : 0
+        }
+        const difference = priority(a) - priority(b)
+        if (difference) return difference
         const am = this.store.require<Message>(a.owner, 'message', a.work.messageId), bm = this.store.require<Message>(b.owner, 'message', b.work.messageId)
         return am.createdAt - bm.createdAt || (a.work.conversationId === b.work.conversationId && a.work.conversationTaskId === b.work.conversationTaskId ? am.seq - bm.seq : 0) || a.work.createdAt - b.work.createdAt || a.work.id.localeCompare(b.work.id)
       })
@@ -222,19 +229,24 @@ export abstract class WorkspaceScheduler {
         if (this.executing.size >= 4 || !['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' && c.mode === 'host' && work.depth === 0 && work.batchId === root.id && work.requiredReply && !work.currentMessageId) {
+        if (work.status === 'queued' && !root.assignmentId && c.mode === 'host' && work.depth === 0 && work.batchId === root.id && work.requiredReply && !work.currentMessageId) {
           work.agentId = c.administratorId
         }
         if (work.status === 'queued' && (c.archived || root.stopRequested || !c.memberIds.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)
+        // 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
         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 === 'uncertain' && (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' || t.runId !== work.runId || t.batchId !== work.batchId || t.agentId === c.administratorId || work.agentId === c.administratorId)) continue
+          if (inTask.some(t => c.kind === 'direct'
+            || (!root.assignmentId && (t.runId !== work.runId || t.batchId !== work.batchId))
+            || t.agentId === c.administratorId || work.agentId === c.administratorId)) continue
         }
         this.executing.add(work.id)
         const recovering = work.status === 'uncertain'

+ 17 - 3
src/server/workspaceStore.ts

@@ -98,6 +98,15 @@ export function parse<T>(schema: z.ZodType<T>, value: unknown): T {
   return parsed.data
 }
 export class WorkspaceStore {
+  private observers = new Set<(owner: string, event: WorkspaceEvent) => void>()
+  observe(listener: (owner: string, event: WorkspaceEvent) => void): () => void {
+    this.observers.add(listener)
+    return () => { this.observers.delete(listener) }
+  }
+  private publish(owner: string, event: WorkspaceEvent): void {
+    this.changes.emit(owner, event)
+    for (const listener of this.observers) { try { listener(owner, event) } catch { /* Observers cannot undo a committed transaction. */ } }
+  }
   readonly db: DatabaseSync
   readonly changes = new EventEmitter()
   private transactionEvents: Array<{ owner: string; event: WorkspaceEvent }> | undefined
@@ -209,7 +218,7 @@ export class WorkspaceStore {
       this.db.exec('COMMIT')
       const events = this.transactionEvents
       this.transactionEvents = undefined
-      for (const entry of events) this.changes.emit(entry.owner, entry.event)
+      for (const entry of events) this.publish(entry.owner, entry.event)
       return result
     } catch (error) {
       this.db.exec('ROLLBACK')
@@ -225,7 +234,7 @@ export class WorkspaceStore {
     )
     const event = { seq, type, conversationId, data }
     if (this.transactionEvents) this.transactionEvents.push({ owner, event })
-    else this.changes.emit(owner, event)
+    else this.publish(owner, event)
     return seq
   }
   events(owner: string, after: number): WorkspaceEvent[] {
@@ -265,8 +274,9 @@ export class WorkspaceStore {
       return result
     })
   }
-  createAgent(owner: string, input: unknown): Agent {
+  createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string }): Agent {
     const body = parse(agentInput, input)
+    if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     return this.atomic(() => {
       if (
         this.list<Agent>(owner, 'agent').some(
@@ -278,10 +288,12 @@ export class WorkspaceStore {
         id = randomUUID()
       const agent: Agent = {
         ...body,
+        ...origin,
         avatar: body.avatar ? normalizeAvatar(body.avatar) : encodeAgentAvatar(randomAgentIdentity(id, body.name)),
         id,
         archived: false,
         revision: 1,
+        teamAuthorizationVersion: body.canManageTeam ? 1 : 0,
         createdAt: now,
         updatedAt: now,
       }
@@ -326,6 +338,8 @@ export class WorkspaceStore {
       )
         throw new HttpError(409, 'Agent 名称已存在', 'duplicate_agent_name')
       const next = { ...agent, ...patch, revision: agent.revision + 1, updatedAt: Date.now() }
+      if (patch.canManageTeam !== undefined && patch.canManageTeam !== (agent.canManageTeam === true))
+        next.teamAuthorizationVersion = (agent.teamAuthorizationVersion ?? 0) + 1
       this.put(owner, 'agent', id, next)
       this.event(owner, 'agent.changed', next)
       for (const c of this.list<Conversation>(owner, 'conversation').filter(

+ 61 - 6
src/server/workspaceTeamTools.ts

@@ -7,6 +7,7 @@ import type { WorkspaceRuntime } from './workspaceRuntime.js'
 import 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 } from './taskCoordinator.js'
 
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
@@ -23,8 +24,12 @@ const startTask = z.object({
   teamId: uuid,
   title: z.string().trim().min(1).max(100),
   content: z.string().trim().min(1).max(32_000),
+  acceptanceCriteria: z.array(z.string().trim().min(1).max(1000)).max(12).default([]),
 }).strict()
 const getTask = z.object({ teamId: uuid, taskId: uuid }).strict()
+const editCreatedAgent = z.object({requestId:uuid,agentId:uuid,name:z.string().trim().min(1).max(100).optional(),instructions:z.string().max(24000).optional()}).strict()
+const archiveCreatedAgent = z.object({requestId:uuid,agentId:uuid,confirmName:z.string().min(1).max(100)}).strict()
+const editTeam = z.object({requestId:uuid,teamId:uuid,name:z.string().trim().min(1).max(100).optional(),instructions:z.string().max(24000).optional(),memberIds:z.array(uuid).min(2).max(8).optional(),archived:z.boolean().optional()}).strict()
 
 const definitions = [
   ['workspace_list_sources', '查看当前账号获准使用的基础 Agent。创建成员前先查询。', empty],
@@ -34,9 +39,18 @@ const definitions = [
   ['workspace_create_team', '建立持久团队,自动把你加入并设为管理员,其他成员使用真实 Agent ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
   ['workspace_start_team_task', '在你管理的团队启动独立任务,由管理员分派并复核。立即返回任务编号,不表示完成;请向用户提供团队名称和任务名称。requestId 使用新的 UUID,重试时复用。', startTask],
   ['workspace_get_team_task', '读取你管理的团队任务的运行状态、最近 20 条结果和待处理问题。运行中不代表目标已完成;避免频繁轮询,用户可在团队内查看进展。', getTask],
+  ['workspace_assign_task', '为团队目标创建有负责人、依赖和验收要求的子任务。仅可分派给团队内其他成员,依赖必须先完成复核。requestId 为重试复用的 UUID。', assignmentInput],
+  ['workspace_review_assignment', '管理员复核子任务:accept 接受实际结果,retry 附具体修改意见后返工,blocked 记录受阻原因。失败执行不能直接验收通过。', assignmentReview],
+  ['workspace_update_assignment', '调整尚未运行或已失败子任务的说明、验收要求和依赖。禁止依赖循环;正在执行的任务需要先停止。', assignmentUpdate],
+  ['workspace_cancel_assignment', '停止一个子任务,必须记录原因。执行停止尚未确认时显示正在停止,不能当作已经撤销外部副作用。其他子任务和团队不受影响。', assignmentCancel],
+  ['workspace_finish_team_task', '根据实际结果记录目标完成、受阻或等待用户。完成前所有子任务必须验收通过;结果会自动送回原始会话。', finishGoalInput],
+  ['workspace_resume_team_task', '仅在当前用户的新指令中恢复受阻、等待或停止的目标。旧执行状态不确定时拒绝恢复;自动回传不能自行重新激活旧目标。', resumeGoalInput],
+  ['workspace_update_created_agent', '调整你创建的成员的名称或职责,不能修改基础 Profile 或权限。变更从后续轮次生效。', editCreatedAgent],
+  ['workspace_archive_created_agent', '归档你创建且已不再被有效团队引用的空闲成员,保留历史;confirmName 必须与当前名称完全一致。不能归档其他人创建的成员。', archiveCreatedAgent],
+  ['workspace_update_team', '调整你管理的空闲团队名称、规则或成员,或归档已结束的团队。不能更换管理员或修改正在执行的团队。', editTeam],
 ] as const
 
-export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础 Agent、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务。创建的成员会持久保存;每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。已经在团队中时优先直接 @现有成员协作,不要递归创建同类团队或为当前任务重复启动任务。工具返回 queued/running 只表示已启动,请提供团队名称和任务名称供用户查看;完成必须以实际结果为据。新成员没有组队权限,角色描述不能扩展权限。'
+export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础 Agent、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务并列出验收要求。成员会持久保存;每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。有团队目标时使用 workspace_assign_task 分派明确的子任务和依赖,使用 workspace_review_assignment 复核结果;普通群聊仍可 @成员。不要递归创建同类团队或重复启动当前任务。queued/running 只表示已启动;结果会回到原会话,完成必须提供对应验收依据。受阻或停止的目标只能在用户新指令下恢复。新成员没有组队权限,角色描述不能扩展权限。'
 
 /** The caller identity comes exclusively from the running server-side turn. */
 export class WorkspaceTeamTools {
@@ -57,8 +71,9 @@ export class WorkspaceTeamTools {
     const c = this.store.require<Conversation>(owner, 'conversation', work.conversationId)
     const root = this.store.require<Run>(owner, 'run', work.runId)
     this.nodes.requireSource(owner, agent)
-    if (agent.canManageTeam !== true || agent.archived || agent.nodeId !== 'local' || agent.remoteAgentId
+    if (agent.canManageTeam !== true || agent.archived || agent.nodeId !== 'local' || agent.remoteAgentId || root.assignmentId
       || work.teamManagementRevision !== agent.revision
+      || (root.authorizationVersion !== undefined && root.authorizationVersion !== this.runtime.authorizationVersion(owner))
       || c.archived || !c.memberIds.includes(agent.id) || work.cancelRequested || root.stopRequested
       || !['running', 'waiting'].includes(work.status))
       throw new HttpError(403, '当前 Agent 的本轮组队授权已失效', 'team_tools_forbidden')
@@ -119,7 +134,7 @@ export class WorkspaceTeamTools {
       return this.store.command(owner, requestId, { agentId: agent.id, toolId, body }, () => {
         if (this.store.list<Agent>(owner, 'agent').filter(a => !a.archived).length >= 100)
           throw new HttpError(409, '自动组队最多保留 100 个有效 Agent,请先复用或归档已有成员。', 'team_agent_limit')
-        const created = this.store.createAgent(owner, { ...body, canManageTeam: false })
+        const created = this.store.createAgent(owner, { ...body, canManageTeam: false }, { createdByAgentId: agent.id, createdFromRunId: work.runId })
         return { agent: this.store.agentSummary(created) }
       })
     }
@@ -151,8 +166,10 @@ export class WorkspaceTeamTools {
         const task = emptyTask
           ? this.store.updateTask(owner, team.id, emptyTask.id, { title: body.title })
           : this.store.createTask(owner, team.id, { title: body.title })
-        const run = this.runtime.send(owner, team.id, { requestId: randomUUID(), taskId: task.id, content: body.content })
-        return { teamId: team.id, teamName: team.name, task, run, message: '任务已启动,尚未完成。可在团队中查看进展和最终结果。' }
+        const goal = this.runtime.tasks.begin(owner, task, agent, body.content,
+          { conversationId: work.conversationId, conversationTaskId: work.conversationTaskId, runId: work.runId, agentId: agent.id }, body.acceptanceCriteria)
+        const run = this.runtime.dispatch(owner, team.id, { requestId: randomUUID(), taskId: task.id, content: `由 Agent「${agent.name}」发起的团队任务:\n${body.content}` }, { agentId: agent.id, kind: 'task_review' })
+        return { teamId: team.id, teamName: team.name, task: { ...task, goal }, run, message: '任务已启动,尚未完成。执行结果会回到原会话,由管理员复核。' }
       })
     }
     if (toolId === 'workspace_get_team_task') {
@@ -166,7 +183,45 @@ export class WorkspaceTeamTools {
       const interactions = this.store.list<WorkspaceInteraction>(owner, 'interaction')
         .filter(i => i.conversationId === team.id && i.conversationTaskId === task.id && !i.resolved)
         .map(({ agentId, kind, message, choices }) => ({ agentId, kind, message, choices }))
-      return { teamId: team.id, teamName: team.name, task, runs, messages, interactions }
+      return { teamId: team.id, teamName: team.name, task, runs, messages, interactions,
+        assignments: this.runtime.tasks.assignments(owner, task.id) }
+    }
+    if (toolId === 'workspace_assign_task') return { assignment: this.runtime.tasks.createAssignment(owner, agent.id, input) }
+    if (toolId === 'workspace_review_assignment') return { assignment: this.runtime.tasks.reviewAssignment(owner, agent.id, input) }
+    if (toolId === 'workspace_update_assignment') return { assignment: this.runtime.tasks.updateAssignment(owner, agent.id, input) }
+    if (toolId === 'workspace_cancel_assignment') return { assignment: this.runtime.tasks.cancelAssignment(owner, agent.id, input) }
+    if (toolId === 'workspace_finish_team_task') return { goal: this.runtime.tasks.finish(owner, agent.id, input, work.runId) }
+    if (toolId === 'workspace_resume_team_task') return { goal: this.runtime.tasks.resume(owner,agent.id,input,{conversationId:work.conversationId,conversationTaskId:work.conversationTaskId,runId:work.runId,agentId:agent.id}) }
+    if (toolId === 'workspace_update_created_agent' || toolId === 'workspace_archive_created_agent') {
+      const body = toolId === 'workspace_update_created_agent' ? parse(editCreatedAgent,input) : parse(archiveCreatedAgent,input)
+      const target = this.store.require<Agent>(owner,'agent',body.agentId)
+      if (target.id === agent.id || target.createdByAgentId !== agent.id) throw new HttpError(403,'只能管理自己创建的成员','agent_creator_required')
+      this.nodes.requireSource(owner,target)
+      return this.store.command(owner,body.requestId,{agentId:agent.id,toolId,body},()=>{
+        if ('confirmName' in body) {
+          if (body.confirmName !== target.name) throw new HttpError(400,'成员名称不匹配','agent_name_mismatch')
+          if (this.store.list<Conversation>(owner,'conversation').some(c=>c.kind==='group'&&!c.archived&&c.memberIds.includes(target.id)))
+            throw new HttpError(409,'成员仍被有效团队使用,请先调整或归档团队','agent_in_use')
+          if (this.store.list<Work>(owner,'turn').some(w=>w.agentId===target.id&&!['complete','failed','interrupted'].includes(w.status)))
+            throw new HttpError(409,'成员仍在执行任务','agent_in_use')
+          return {agent:this.store.agentSummary(this.store.updateAgent(owner,target.id,{archived:true}))}
+        }
+        const {requestId:_request,agentId:_target,...patch}=body
+        return {agent:this.store.agentSummary(this.store.updateAgent(owner,target.id,patch))}
+      })
+    }
+    if (toolId === 'workspace_update_team') {
+      const {requestId,...body}=parse(editTeam,input), team=this.team(owner,agent.id,body.teamId)
+      if (this.store.list<Run>(owner,'run').some(r=>r.conversationId===team.id&&!['complete','failed','interrupted'].includes(r.status)))
+        throw new HttpError(409,'请在团队当前执行结束后调整','team_busy')
+      if (body.archived && this.store.list<import('../shared/agentTasks.js').AgentGoal>(owner,'goal').some(g=>g.conversationId===team.id&&!['complete','blocked','cancelled'].includes(g.status)))
+        throw new HttpError(409,'团队仍有未结束的目标','team_busy')
+      if (body.memberIds && !body.memberIds.includes(agent.id)) throw new HttpError(400,'团队必须保留当前管理员','invalid_members')
+      for (const id of body.memberIds??[]) this.nodes.requireSource(owner,this.store.require<Agent>(owner,'agent',id))
+      return this.store.command(owner,requestId,{agentId:agent.id,toolId,body},()=>{
+        const {teamId:_team,...patch}=body
+        return {team:this.store.updateConversation(owner,team.id,patch)}
+      })
     }
     throw new HttpError(404, '本轮未授权此工具', 'team_tool_not_found')
   }

+ 60 - 0
src/shared/agentTasks.ts

@@ -0,0 +1,60 @@
+export interface TaskOrigin {
+  conversationId: string
+  conversationTaskId?: string
+  runId: string
+  agentId: string
+}
+
+export interface AgentGoal {
+  id: string
+  conversationId: string
+  coordinatorId: string
+  authorityRevision: number
+  authorizationVersion?: number
+  activation?: number
+  objective: string
+  acceptanceCriteria: string[]
+  status: 'running' | 'review' | 'waiting' | 'blocked' | 'complete' | 'cancelling' | 'cancelled'
+  origin: TaskOrigin
+  result?: string
+  checks?: Array<{ criterion: number; passed: boolean; evidence: string }>
+  artifactIds: string[]
+  revision: number
+  automaticWakes: number
+  reviewRunId?: string
+  reportingRunId?: string
+  lastReviewedBatch?: string
+  createdAt: number
+  updatedAt: number
+}
+
+export interface AgentAssignment {
+  id: string
+  goalId: string
+  conversationId: string
+  agentId: string
+  title: string
+  brief: string
+  acceptanceCriteria: string[]
+  dependsOn: string[]
+  status: 'pending' | 'running' | 'review' | 'complete' | 'failed' | 'blocked' | 'cancelling' | 'cancelled'
+  attempt: number
+  maxAttempts?: number
+  runId?: string
+  result?: string
+  review?: string
+  artifactIds: string[]
+  createdAt: number
+  updatedAt: number
+}
+
+export interface TaskDelivery {
+  id: string
+  goalId: string
+  kind: 'review' | 'terminal'
+  status: 'pending' | 'delivered' | 'suppressed'
+  targetConversationId: string
+  targetTaskId?: string
+  runId?: string
+  createdAt: number
+}

+ 10 - 0
src/shared/workspace.ts

@@ -6,6 +6,9 @@ export interface WorkspaceAgent {
   instructions: string
   /** Explicit account-owner grant; absent on older records means disabled. */
   canManageTeam?: boolean
+  teamAuthorizationVersion?: number
+  createdByAgentId?: string
+  createdFromRunId?: string
   nodeId: string
   profile: string
   remoteAgentId?: string
@@ -61,6 +64,7 @@ export interface WorkspaceTask {
   lastMessageAt?: number
   createdAt: number
   updatedAt: number
+  goal?: import('./agentTasks.js').AgentGoal
 }
 export interface WorkspaceFile {
   id: string
@@ -81,6 +85,7 @@ export interface WorkspaceMessage {
   conversationTaskId?: string
   seq: number
   role: 'user' | 'assistant' | 'system'
+  taskReference?: { conversationId: string; taskId: string }
   agentId?: string
   agentName?: string
   content: string
@@ -105,6 +110,11 @@ export interface WorkspaceRun {
   round: number
   error?: string
   stopRequested?: boolean
+  assignmentId?: string
+  targetAgentId?: string
+  triggerKind?: 'assignment' | 'task_review' | 'task_result'
+  internalInstruction?: string
+  authorizationVersion?: number
   createdAt: number
   updatedAt: number
 }

+ 30 - 0
tests/e2e/team-tools.spec.ts

@@ -35,3 +35,33 @@ test('team permission defaults off, saves, reloads and revokes in the Agent edit
   expect((await (await revoked).json()).agent.canManageTeam).toBe(false)
   expect((await (await page.request.get('/api/app/agents')).json()).agents.find((a:any)=>a.id===agent.id).canManageTeam).toBe(false)
 })
+
+test('task plans show owners and keep independent text and attachment drafts when switching tasks',async({page},testInfo)=>{
+  await page.goto('/conversations')
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+  const seeded=await page.request.post('/__test/task-plan',{data:{}})
+  expect(seeded.ok()).toBe(true)
+  const ids=await seeded.json()
+  await page.goto(`/conversations/${ids.sourceConversationId}`)
+  await page.getByRole('link',{name:'打开任务',exact:true}).click()
+  await expect(page).toHaveURL(new RegExp(`/conversations/${ids.conversationId}\\?taskId=${ids.firstTaskId}`))
+  await page.locator('details.task-plan summary').click()
+  await expect(page.locator('.task-plan')).toContainText('研究资料')
+  await expect(page.locator('.task-plan')).toContainText('验收研究员')
+  const composer=page.getByRole('textbox',{name:'输入消息,Enter 发送,Shift + Enter 换行'})
+  await composer.fill('调研任务的草稿')
+  await page.locator('.composer-area input[type=file]').first().setInputFiles({name:'draft.txt',mimeType:'text/plain',buffer:Buffer.from('待发送附件')})
+  await page.getByRole('combobox',{name:'当前任务'}).selectOption(ids.secondTaskId)
+  await expect(composer).toHaveValue('')
+  await composer.fill('独立任务的草稿')
+  await page.getByRole('combobox',{name:'当前任务'}).selectOption(ids.firstTaskId)
+  await expect(composer).toHaveValue('调研任务的草稿')
+  await expect(page.locator('.composer-attachments')).toContainText('draft.txt')
+  await page.screenshot({path:testInfo.outputPath('task-plan-drafts.png')})
+  if(!await page.getByRole('button',{name:'停止任务',exact:true}).isVisible())await page.locator('details.task-plan summary').click()
+  await page.getByRole('button',{name:'停止任务',exact:true}).click()
+  await expect(page.locator('.task-plan')).toContainText('已停止')
+})

+ 17 - 0
tests/fixtures/workspace-server.ts

@@ -331,6 +331,23 @@ if (!existsSync(usersPath)) {
 const auth = new LocalAuthStore(home, false)
 const runtime = createApplication({ config, auth }),
   node = createNodeServer(runtime)
+runtime.app.use((ctx,next) => {
+  if (ctx.path !== '/__test/task-plan' || ctx.method !== 'POST') return next()
+  const owner=auth.require(ctx).id
+  const lead=runtime.workspace.createAgent(owner,{name:'验收负责人',profile:'default',canManageTeam:true})
+  const worker=runtime.workspace.createAgent(owner,{name:'验收研究员',profile:'default'})
+  const team=runtime.workspace.createGroup(owner,{name:'任务分工验收',memberIds:[lead.id,worker.id],administratorId:lead.id})
+  const first=runtime.workspace.tasks(owner,team.id)[0]!
+  runtime.workspace.updateTask(owner,team.id,first.id,{title:'调研任务'})
+  const second=runtime.workspace.createTask(owner,team.id,{title:'独立任务'})
+  const source=runtime.workspace.list<any>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===lead.id)!
+  const goal=runtime.workspaceRuntime.tasks.begin(owner,first,lead,'完成可核对的研究报告',{conversationId:source.id,runId:randomUUID(),agentId:lead.id},['来源可核对'])
+  const assignment={id:randomUUID(),goalId:goal.id,conversationId:team.id,agentId:worker.id,title:'研究资料',brief:'核对资料',acceptanceCriteria:['来源可核对'],dependsOn:[],status:'review',attempt:1,result:'资料已汇总',artifactIds:[],createdAt:Date.now(),updatedAt:Date.now()}
+  runtime.workspace.put(owner,'assignment',assignment.id,assignment)
+  runtime.workspace.event(owner,'assignment.changed',assignment,team.id)
+  runtime.workspace.saveMessage(owner,{id:randomUUID(),conversationId:source.id,seq:0,role:'system',content:`任务已有结果。[打开任务](/conversations/${team.id}?taskId=${first.id})`,reasoning:'',status:'complete',attachments:[],tools:[],createdAt:Date.now()})
+  ctx.body={conversationId:team.id,firstTaskId:first.id,secondTaskId:second.id,sourceConversationId:source.id}
+})
 runtime.app.use(serve(resolve('dist')))
 runtime.app.use((ctx) => {
   if (!ctx.path.startsWith('/api/')) {

+ 8 - 0
tests/live/workspaceTeamTools.live.test.ts

@@ -56,6 +56,14 @@ it.skipIf(!url)('uses actual Hermes auth, sessions and native plugin tools to cr
     expect(task.title).toBe('实际启动验证')
     expect(app.workspace.list<WorkspaceMessage>(owner,'message').some(m=>m.conversationId===team.id && m.role==='assistant' && m.content)).toBe(true)
     await invoke('workspace_get_team_task',{teamId:team.id,taskId:task.id})
+    await invoke('workspace_assign_task',{requestId:randomUUID(),goalId:task.id,agentId:member.id,title:'核验执行权限',brief:'检查并报告当前执行环境。'})
+    await expect.poll(()=>app.workspaceRuntime.tasks.assignments(owner,task.id)[0]?.status,{timeout:30_000}).toBe('review')
+    const assignment=app.workspaceRuntime.tasks.assignments(owner,task.id)[0]!
+    expect(JSON.parse(assignment.result!).tool_count).toBe(0)
+    await invoke('workspace_review_assignment',{requestId:randomUUID(),assignmentId:assignment.id,decision:'accept',review:'已核对成员未继承组队工具。'})
+    await invoke('workspace_finish_team_task',{requestId:randomUUID(),goalId:task.id,status:'complete',result:'团队执行权限核验完成。',checks:[{criterion:0,passed:true,evidence:'成员真实原生工具目录未获得组队权限,执行结果已经返回。'}]})
+    await expect.poll(()=>app.workspace.tasks(owner,team.id)[0]?.goal?.status).toBe('complete')
+    expect(app.workspace.messages(owner,direct.id).some(m=>m.taskReference?.taskId===task.id && m.role==='system')).toBe(true)
     app.workspace.updateAgent(owner,manager.id,{canManageTeam:false})
     await invoke('workspace_create_agent',{requestId:randomUUID(),name:'关闭后不应创建',profile:'default'},false)
     expect(app.workspace.list(owner,'agent')).toHaveLength(2)

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

@@ -0,0 +1,141 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, 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 { WorkspaceStore } from '../../src/server/workspaceStore'
+import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
+import { UploadStore } from '../../src/server/uploads'
+import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
+import type { WorkspaceAgent, WorkspaceConversation, WorkspaceRun } from '../../src/shared/workspace'
+import type { AgentGoal, AgentAssignment, TaskDelivery } from '../../src/shared/agentTasks'
+
+let home: string, store: WorkspaceStore, runtime: WorkspaceRuntime, uploads: UploadStore
+let lead: WorkspaceAgent, worker: WorkspaceAgent, second: WorkspaceAgent, team: WorkspaceConversation, goal: AgentGoal
+let active: boolean, authorizationVersion: number
+const owner = 'task-owner'
+beforeEach(() => {
+  home=mkdtempSync(join(tmpdir(),'yaoyao-goal-tests-'));store=new WorkspaceStore(home);uploads=new UploadStore(home);active=true;authorizationVersion=1
+  runtime=new WorkspaceRuntime(store,{requireSource:()=>{}} as unknown as WorkspaceNodes,uploads,()=>active,()=>authorizationVersion)
+  vi.spyOn(runtime,'wake').mockImplementation(()=>{})
+  lead=store.createAgent(owner,{name:'老板',profile:'default',canManageTeam:true})
+  worker=store.createAgent(owner,{name:'研究',profile:'default'})
+  second=store.createAgent(owner,{name:'编辑',profile:'default'})
+  team=store.createGroup(owner,{name:'工作团队',memberIds:[lead.id,worker.id,second.id],administratorId:lead.id})
+  const source=store.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===lead.id)!
+  const origin=runtime.send(owner,source.id,{requestId:randomUUID(),content:'完成调研报告'})
+  origin.status='complete';store.saveRun(owner,origin)
+  goal=runtime.tasks.begin(owner,store.tasks(owner,team.id)[0]!,lead,'完成调研报告',{conversationId:source.id,runId:origin.id,agentId:lead.id},['内容可核对'])
+})
+afterEach(()=>{runtime.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})})
+
+function assign(name:string,agent=worker,dependsOn:string[]=[]) {
+  return runtime.tasks.createAssignment(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,agentId:agent.id,title:name,brief:'提交实际结果',acceptanceCriteria:['内容可核对'],dependsOn})
+}
+function settle(runId:string,status:WorkspaceRun['status']='complete',text='有依据的执行结果') {
+  const run=store.require<WorkspaceRun>(owner,'run',runId)
+  if(text)store.saveMessage(owner,{id:randomUUID(),conversationId:run.conversationId,conversationTaskId:run.conversationTaskId,
+    seq:0,role:'assistant',agentId:run.targetAgentId||lead.id,content:text,reasoning:'',status:status==='complete'?'complete':'failed',runId,createdAt:Date.now(),attachments:[],tools:[]})
+  run.status=status;store.saveRun(owner,run)
+}
+const assignment=(id:string)=>store.require<AgentAssignment>(owner,'assignment',id)
+
+describe('durable team task coordination',()=>{
+  it('runs dependencies only after review, returns a result to the original conversation once',async()=>{
+    const a=assign('调研'),b=assign('撰写',second,[a.id])
+    await vi.waitFor(()=>expect(assignment(a.id).status).toBe('running'))
+    expect(assignment(b.id).status).toBe('pending')
+    const run=store.require<WorkspaceRun>(owner,'run',assignment(a.id).runId!)
+    expect(run).toMatchObject({targetAgentId:worker.id,assignmentId:a.id,triggerKind:'assignment'})
+    settle(run.id)
+    await vi.waitFor(()=>expect(assignment(a.id).status).toBe('review'))
+    expect(assignment(b.id).status).toBe('pending')
+    await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(1))
+    const delivered=store.list<TaskDelivery>(owner,'task-delivery')[0]!
+    expect(delivered.targetConversationId).toBe(goal.origin.conversationId)
+    expect(store.messages(owner,goal.origin.conversationId).at(-1)?.role).toBe('system')
+    runtime.tasks.wake();await new Promise(resolve=>setTimeout(resolve,5))
+    expect(store.list(owner,'task-delivery')).toHaveLength(1)
+    runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:a.id,decision:'accept',review:'来源和内容均已核对'})
+    await vi.waitFor(()=>expect(assignment(b.id).status).toBe('running'))
+    expect(()=>runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'报告完成'})).toThrow('未验收')
+    settle(assignment(b.id).runId!)
+    await vi.waitFor(()=>expect(assignment(b.id).status).toBe('review'))
+    runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:b.id,decision:'accept',review:'报告满足要求'})
+    const ended=runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'报告已完成,已复核来源和内容',checks:[{criterion:0,passed:true,evidence:'已逐项核对调研和撰写结果'}]},delivered.runId)
+    expect(ended.status).toBe('complete')
+    settle(delivered.runId!,'complete','报告已完成,已复核来源和内容')
+    await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery').filter(d=>d.kind==='terminal'&&d.status==='delivered')).toHaveLength(1))
+    const terminal=store.list<TaskDelivery>(owner,'task-delivery').find(d=>d.kind==='terminal')!
+    expect(terminal.runId).toBe(delivered.runId)
+  })
+
+  it('does not treat a failed execution as accepted and supports bounded, explicit rework',async()=>{
+    const a=assign('调研')
+    await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
+    settle(assignment(a.id).runId!,'failed','检索失败')
+    await vi.waitFor(()=>expect(assignment(a.id).status).toBe('failed'))
+    expect(()=>runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:a.id,decision:'accept',review:'忽略错误'})).toThrow('失败')
+    const old=assignment(a.id).runId
+    runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:a.id,decision:'retry',review:'缩小检索范围后重试'})
+    await vi.waitFor(()=>expect(assignment(a.id).runId).not.toBe(old))
+    expect(assignment(a.id).attempt).toBe(2)
+  })
+
+  it('rejects cross-goal dependencies and another coordinator',()=>{
+    expect(()=>assign('非法依赖',worker,[randomUUID()])).toThrow('依赖')
+    expect(()=>runtime.tasks.createAssignment(owner,worker.id,{requestId:randomUUID(),goalId:goal.id,agentId:second.id,title:'越权',brief:'不应执行'})).toThrow('无权')
+    expect(()=>runtime.tasks.finish('another',lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'伪造'})).toThrow()
+  })
+
+  it.each(['disabled','credentials','permission','regranted','origin_stopped'])('never resurrects a stopped or revoked goal: %s',async reason=>{
+    const a=assign('调研')
+    await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
+    if(reason==='disabled')active=false
+    if(reason==='credentials')authorizationVersion++
+    if(reason==='permission'||reason==='regranted')store.updateAgent(owner,lead.id,{canManageTeam:false})
+    if(reason==='regranted')store.updateAgent(owner,lead.id,{canManageTeam:true})
+    if(reason==='origin_stopped'){
+      const source=store.require<WorkspaceRun>(owner,'run',goal.origin.runId);source.stopRequested=true;store.saveRun(owner,source)
+    }
+    settle(assignment(a.id).runId!)
+    runtime.tasks.wake()
+    await new Promise(resolve=>setTimeout(resolve,30))
+    expect(store.list<TaskDelivery>(owner,'task-delivery').filter(d=>d.status==='delivered')).toEqual([])
+  })
+  it('resumes only from a new user instruction after previous execution is confirmed stopped',async()=>{
+    const a=assign('调研')
+    await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
+    const old=assignment(a.id).runId!
+    runtime.tasks.cancel(owner,goal.id)
+    const source=runtime.send(owner,goal.origin.conversationId,{requestId:randomUUID(),content:'继续这个任务'})
+    const origin={...goal.origin,runId:source.id}
+    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 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)
+    await vi.waitFor(()=>expect(assignment(a.id).runId).not.toBe(old))
+    expect(assignment(a.id).attempt).toBe(2)
+  })
+  it('requires a result for every acceptance criterion',()=>{
+    expect(()=>runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'完成'})).toThrow('逐项')
+    expect(()=>runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'完成',checks:[{criterion:0,passed:false,evidence:'尚未核对'}]})).toThrow('逐项')
+  })
+
+  it('repairs a missed result dispatch without duplicating the durable source message',async()=>{
+    const a=assign('调研')
+    await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
+    const dispatch=vi.spyOn(runtime,'dispatch')
+    dispatch.mockImplementationOnce(()=>{throw new Error('simulate dispatch crash')})
+    settle(assignment(a.id).runId!)
+    await vi.waitFor(()=>expect(assignment(a.id).status).toBe('review'))
+    runtime.tasks.wake()
+    await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery').filter(d=>d.status==='delivered')).toHaveLength(1))
+    const count=store.messages(owner,goal.origin.conversationId).length
+    runtime.tasks.wake();await new Promise(resolve=>setTimeout(resolve,10))
+    expect(store.messages(owner,goal.origin.conversationId)).toHaveLength(count)
+  })
+})

+ 42 - 14
tests/server/workspace.test.ts

@@ -109,6 +109,23 @@ afterEach(async () => {
 function agent(name: string, user = owner) {
   return store.createAgent(user, { name, profile: 'default', instructions: `规则:你是${name}` })
 }
+it('executes a structured assignment on its actual worker and leaves acceptance to its coordinator', async () => {
+  const lead = store.updateAgent(owner, agent('负责人').id, { canManageTeam: true })
+  const worker = agent('研究成员')
+  const source = direct(lead.id)
+  const original = runtime.send(owner, source.id, { requestId: randomUUID(), content: '完成研究' })
+  for (const work of store.list<any>(owner, 'turn').filter(w => w.runId === original.id))
+    store.put(owner, 'turn', work.id, { ...work, status: 'complete', planned: true })
+  original.status = 'complete';store.saveRun(owner, original)
+  const team = store.createGroup(owner, { name: '研究团队', memberIds: [lead.id,worker.id], administratorId: lead.id })
+  const goal = runtime.tasks.begin(owner, store.tasks(owner, team.id)[0]!, lead, '研究事实', { conversationId: source.id, runId: original.id, agentId: lead.id })
+  const assignment = runtime.tasks.createAssignment(owner, lead.id, { requestId: randomUUID(), goalId: goal.id, agentId: worker.id, title: '核对事实', brief: '提交核对结果' })
+  await vi.waitFor(() => expect(store.get<any>(owner,'assignment',assignment.id)?.status).toBe('review'), { timeout: 5000 })
+  const submitted = requests.find(r => r.method === 'prompt.submit' && String(r.params.text).includes('核对事实'))!
+  expect(submitted.params.text).toContain('你是 研究成员')
+  expect(submitted.params.text).toContain('执行子任务')
+  expect(store.get<any>(owner,'goal',goal.id)?.status).not.toBe('complete')
+})
 function direct(id: string, user = owner) {
   return store
     .list<WorkspaceConversation>(user, 'conversation')
@@ -270,7 +287,7 @@ describe('Web-owned workspace', () => {
     }
   })
 
-  it('isolates messages, Hermes sessions, bindings, and context between concurrent group tasks', async () => {
+  it('serializes one Agent across group tasks 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, {
@@ -283,18 +300,23 @@ describe('Web-owned workspace', () => {
     const pending: Array<{ socket: WebSocket; params: Record<string, any> }> = []
     reply = (socket, params) => {
       pending.push({ socket, params })
-      if (pending.length !== 2) return
-      for (const item of pending) {
+    }
+    const complete = (item: typeof pending[number]) => {
         const text = item.params.text.includes('第一项工作') ? '第一项结果' : '第二项结果'
         setTimeout(() => item.socket.send(JSON.stringify({
           method: 'event',
           params: { type: 'message.complete', session_id: item.params.session_id, payload: { text, status: 'complete' } },
         })), 5)
-      }
     }
     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 Promise.all([finished(firstRun.id), finished(secondRun.id)])
+    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]!)
+    await finished(secondRun.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(['第一项工作', '第一项结果'])
@@ -350,23 +372,29 @@ describe('Web-owned workspace', () => {
       }),
       firstTask = defaultTask(conversation.id),
       secondTask = store.createTask(owner, conversation.id, {})
-    reply = (socket, params) => setTimeout(() => socket.send(JSON.stringify({
+    const pending: Array<{socket:WebSocket;params:Record<string,any>}> = []
+    reply = (socket, params) => { pending.push({socket,params});setTimeout(() => socket.send(JSON.stringify({
       method: 'event',
       params: {
         type: 'approval.request',
         session_id: params.session_id,
         payload: { request_id: 'same-upstream-id', message: '允许执行吗?' },
       },
-    })), 5)
-    runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '任务甲' })
-    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(2))
-    const interactions = store.list<WorkspaceInteraction>(owner, 'interaction'),
-      first = interactions.find(interaction => interaction.conversationTaskId === firstTask.id)!,
-      second = interactions.find(interaction => interaction.conversationTaskId === secondTask.id)!
+    })), 5) }
+    const firstRun=runtime.send(owner, conversation.id, { requestId: randomUUID(), taskId: firstTask.id, content: '任务甲' })
+    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)!
     expect(store.require<any>(owner, 'conversation-task', firstTask.id).activeRunStatus).toBe('waiting')
-    expect(store.require<any>(owner, 'conversation-task', secondTask.id).activeRunStatus).toBe('waiting')
+    expect(store.require<WorkspaceRun>(owner,'run',secondRun.id).status).toBe('queued')
     await runtime.respond(owner, first.id, 'once')
+    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))
+    const second=store.list<WorkspaceInteraction>(owner,'interaction').find(i=>i.conversationTaskId===secondTask.id)!
+    expect(second.id).not.toBe(first.id)
+    expect(store.require<any>(owner, 'conversation-task', secondTask.id).activeRunStatus).toBe('waiting')
+    await runtime.respond(owner,first.id,'once')
     expect(store.require<WorkspaceInteraction>(owner, 'interaction', first.id).resolved).toBe(true)
     expect(store.require<WorkspaceInteraction>(owner, 'interaction', second.id).resolved).toBe(false)
     await Promise.all([runtime.stopTask(owner, conversation.id, firstTask.id), runtime.stopTask(owner, conversation.id, secondTask.id)])

+ 15 - 2
tests/server/workspaceTeamTools.test.ts

@@ -62,6 +62,18 @@ async function buildTeam() {
 }
 
 describe('Agent-owned team tools', () => {
+  it('records creator authority and archives only an unused member it created',async()=>{
+    const child=(await call('create_agent',{requestId:randomUUID(),name:'临时项目成员',profile:'default'})).agent
+    expect(child.createdByAgentId).toBe(manager.id)
+    const edited=await call('update_created_agent',{requestId:randomUUID(),agentId:child.id,instructions:'核验任务结果'})
+    expect(edited.agent.instructions).toBe('核验任务结果')
+    const other=store.createAgent(owner,{name:'用户成员',profile:'default'})
+    await expect(call('archive_created_agent',{requestId:randomUUID(),agentId:other.id,confirmName:other.name})).rejects.toMatchObject({status:403})
+    const team=(await call('create_team',{requestId:randomUUID(),name:'成员管理测试',memberIds:[child.id]})).team
+    await expect(call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:child.name})).rejects.toMatchObject({code:'agent_in_use'})
+    await call('update_team',{requestId:randomUUID(),teamId:team.id,archived:true})
+    expect((await call('archive_created_agent',{requestId:randomUUID(),agentId:child.id,confirmName:child.name})).agent.archived).toBe(true)
+  })
   it('creates durable members, a managed team and one idempotent task with client-visible events', async () => {
     const requestId = randomUUID(), args = {requestId,name:'研究员',profile:'writer',instructions:'研究事实并给出处'}
     const first = await call('create_agent',args)
@@ -80,7 +92,8 @@ describe('Agent-owned team tools', () => {
     expect(started.task.id).toBe(created.task.id)
     const result = await call('get_team_task',{teamId:created.team.id,taskId:started.task.id})
     expect(result.runs).toHaveLength(1)
-    expect(result.messages[0].content).toBe(taskArgs.content)
+    expect(result.messages[0].content).toContain(taskArgs.content)
+    expect(result.messages[0].role).toBe('system')
     expect(store.events(owner,0).map(e=>e.type)).toEqual(expect.arrayContaining(['agent.changed','conversation.changed','task.changed','message.changed','run.changed']))
     expect(store.list('other','agent')).toEqual([])
     expect(await call('list_teams')).toMatchObject({teams:[{id:created.team.id}]})
@@ -181,7 +194,7 @@ describe('loopback tool lease', () => {
     const lease = await mount()
     expect(binding).toMatchObject({native_tools:true,session_id:'runtime',stored_session_id:'stored',profile:'default'})
     const catalog = await (await http('/tools/list',{})).json()
-    expect(catalog.tools).toHaveLength(7)
+    expect(catalog.tools.map((t:any)=>t.name)).toEqual(expect.arrayContaining(['workspace_create_agent','workspace_create_team','workspace_assign_task','workspace_review_assignment','workspace_finish_team_task']))
     expect(JSON.stringify(catalog)).not.toContain(binding!.token)
     const body={callId:'same-call',toolId:catalog.tools.find((t:any)=>t.name==='workspace_create_agent').id,arguments:{requestId:randomUUID(),name:'HTTP 成员',profile:'writer'}}
     const [first,second]=await Promise.all([http('/tools/call',body).then(r=>r.json()),http('/tools/call',body).then(r=>r.json())])