Przeglądaj źródła

新增任务级临时助手与持久退役清理

LSamien 1 miesiąc temu
rodzic
commit
5735cb0d35

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

@@ -80,3 +80,6 @@
 
 - 公网代理阶段:保持电脑无外部网卡,通过专属进程管道建立公网 80/443 连接,拒绝保留地址、内网、宿主网卡地址及混合 DNS 结果。支持命令环境变量与浏览器代理策略。真实 Docker 的 curl/Firefox HTTPS、截图、宿主拒绝和关停测试通过;完整打包 Runner 的 Worker 公网访问测试通过。详见 `docs/computer-network.md`。
 - 联网阶段最终验证:类型检查和 756 项全量测试通过,5 项真实环境测试默认跳过;地址/代理与 BFF 定向 50 项通过,Web 4 项流程测试通过。真实 Firefox 公网页面截图已核对;公网 Worker 的打包 Runner 验收通过。电脑验收容器全部清理,完整目标仍未完成。
+- 临时助手阶段:新增创建并分派任务的工具,助手没有单聊、不是永久团队成员,只能执行所属目标的当前阶段;固化隔离模式,禁止提权和跨任务使用。目标结束后退役,按原 Runner 进行持久清理重试;清理只删除助手执行数据,保留控制面产物。详见 `docs/task-helpers.md`。
+- Web、iOS、Android 已加入临时助手标识和持久成员选择过滤。iOS 27 项 Workspace 测试通过;Android 模型测试和 Debug Kotlin 编译通过。真实 Hermes Worker / Docker 的临时助手执行和退役清理验收已通过。
+- 临时助手最终验收:服务端类型检查与 759 项全量测试通过,5 项真实环境测试默认跳过;打包 Runner 的真实临时助手完成/清理验收通过;Web 4 项流程测试和临时助手标识截图通过。iOS 27 项 Workspace 测试、Android 31 项模型测试和 Debug Kotlin 编译通过。测试容器全部清理。其余镜像管理、电脑接管、共享环境和桌面交付项继续推进。

+ 31 - 0
docs/task-helpers.md

@@ -0,0 +1,31 @@
+# 任务级临时助手
+
+管理员可使用 `workspace_create_helper` 为已有团队目标创建一个临时隔离助手,并同时分派具体子任务。与持久 Agent 不同,临时助手没有独立单聊,不加入团队的永久成员列表,只参与创建它的目标和该目标当前的运行阶段。
+
+## 生命周期与权限
+
+- 创建时记录 `temporaryGoalId`、`helperActivation`、创建者、来源运行和原 Runner ID。调用身份由当前受权轮次提供,模型不能指定这些字段。
+- 工具必须提供目标、名称和具体工作,可补充职责、验收要求和依赖。创建助手与子任务在同一个持久命令中完成,同一 requestId 重试不会重复创建。
+- 每个目标最多 8 个有效临时助手、累计最多 32 个;执行仍受 Agent 串行约束和节点电脑配额限制。
+- 助手固定使用隔离电脑,不获得组队权限,不能变成持久成员、加入其他群聊、切换执行方式或执行其他任务。
+- 用户恢复目标时会产生新阶段。旧阶段的临时助手不恢复;需要继续工作时创建新助手或由管理员调整负责人。
+- 只有声明 `helper-retirement-v1` 的 Runner 才会向管理员提供创建助手工具,避免在不能清理的旧执行节点上创建临时资源。
+
+## 退役与清理
+
+目标完成、受阻、取消、删除,或进入新运行阶段后,旧助手退役。先撤销执行资格,再停止所属运行;不能确认停止时保留待清理状态,不把工作区交给其他任务。
+
+清理状态保存在助手记录中,服务重启后继续检查。离线节点或失败的清理会重试,界面显示“已退役,等待清理”。清理只发往原 Runner;更换节点不会把清理请求发送到另一台电脑。
+
+Runner 在删除资源前保存退役记录和原配置,核对环境归属,停止相关模型进程、释放电脑、移除工作目录和临时模型会话。退役标记保留,迟到请求不能重新创建该电脑。已导出的文件保存在控制面,原作者和任务来源不变,不随工作区删除。
+
+## 跨端表现
+
+Web、iOS 和 Android 的任务进度显示“临时助手”及退役状态。持久团队的成员选择器排除这些助手;后端同时执行任务边界验证,旧客户端或手工请求也不能绕过。
+
+## 验收
+
+- 服务测试覆盖创建去重、跨任务拒绝、禁止提权和转成持久成员、取消后退役、异步清理,以及清理中断后的重试。
+- 真实 Docker / Hermes Worker 验收:管理员创建临时助手,助手完成隔离工作并导出文件;目标结束后工作目录被删除,已回传产物保留。
+- 模型服务在该验收中使用确定的测试响应,没有使用付费模型;它证明执行协议和生命周期,不代表任意任务的自主决策质量。
+- 移动端校验任务身份解码、状态文案和成员选择过滤;完整电脑面板与人工接管仍属于后续交付范围。

+ 1 - 1
src/client/components/workspace/TaskPlan.vue

@@ -11,7 +11,7 @@ const labels: Record<string,string> = {pending:'待执行',running:'进行中',r
     <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>
+        <small>{{ agents.find(a => a.id === item.agentId)?.name || '成员不可用' }}<template v-if="agents.find(a => a.id === item.agentId)?.temporaryGoalId"> · 临时助手{{ agents.find(a => a.id === item.agentId)?.archived ? (agents.find(a => a.id === item.agentId)?.cleanupState === 'pending' ? '(已退役,等待清理)' : '(已退役)') : '' }}</template></small>
         <p v-if="item.review">{{ item.review }}</p>
       </li>
     </ol>

+ 5 - 5
src/client/views/ConversationsView.vue

@@ -161,7 +161,7 @@ function applyPresetRoles(preset: TeamPreset) {
   form.administratorId = form.memberIds[Math.max(0, preset.roles.findIndex(role => role.host))] ?? ''
 }
 function choosePreset(preset?: TeamPreset) {
-  const available = agents.value.filter(a => !a.archived)
+  const available = agents.value.filter(a => !a.archived && !a.temporaryGoalId)
   if (preset && available.length < preset.roles.length) return
   selectedPresetId.value = preset?.id ?? 'custom'
   Object.assign(form, { name: preset?.name ?? '', instructions: preset?.instructions ?? '', memberIds: preset ? available.slice(0, preset.roles.length).map(a => a.id) : [], memberRoles: {}, administratorId: '', mode: 'host', autoReplyIds: [], maxReplyRounds: 3 })
@@ -697,7 +697,7 @@ onBeforeUnmount(() => {
           <button type="button" aria-label="关闭" @click="closeDialog">×</button>
         </header>
         <p v-if="error" class="error" role="alert">{{ error }}</p>
-        <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived).length" @select="choosePreset" />
+        <TeamPresetPicker v-if="dialog === 'group'" :selected="selectedPresetId" :available="agents.filter(a => !a.archived && !a.temporaryGoalId).length" @select="choosePreset" />
         <label>名称<input v-model="form.name" :readonly="isRemoteAgent" required maxlength="100" /></label>
         <p v-if="isRemoteAgent">引用的 Agent 配置由远端管理,请在远端修改名称、头像和角色规则。</p>
         <AgentIdentityPanel v-if="isAgentDialog && !isRemoteAgent" :key="`${dialog}:${editingId}`" :profile="avatarProfile" embedded :show-name="false" :show-default-model="false" :show-actions="false" @avatar-change="form.avatar = $event" />
@@ -750,7 +750,7 @@ onBeforeUnmount(() => {
           <label v-for="(role, index) in selectedPreset.roles" :key="role.name">
             {{ role.name }}{{ role.host ? ' · 管理员' : '' }}
             <select :value="form.memberIds[index]" :aria-label="`${role.name}对应的 Agent`" @change="assignPresetRole(index, ($event.target as HTMLSelectElement).value)">
-              <option v-for="a in agents.filter(a => !a.archived)" :key="a.id" :value="a.id">{{ a.name }}</option>
+              <option v-for="a in agents.filter(a => !a.archived && !a.temporaryGoalId)" :key="a.id" :value="a.id">{{ a.name }}</option>
             </select>
             <small>{{ role.description }}</small>
           </label>
@@ -760,7 +760,7 @@ onBeforeUnmount(() => {
           <legend>选择成员</legend>
           <label
             v-for="a in agents.filter((a) =>
-              !a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id)),
+              !a.temporaryGoalId && (!a.archived || (dialog === 'editGroup' && active?.memberIds.includes(a.id))),
             )"
             :key="a.id"
             ><input
@@ -769,7 +769,7 @@ onBeforeUnmount(() => {
               :value="a.id"
               :disabled="busy || (dialog === 'editGroup' && (a.id === active?.administratorId || a.id === form.administratorId)) || (!form.memberIds.includes(a.id) && form.memberIds.length >= 8)"
             />{{ a.name }}</label
-          ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived).length < 2"
+          ><small v-if="dialog === 'group' && agents.filter((a) => !a.archived && !a.temporaryGoalId).length < 2"
             >至少需要两个 Agent 才能创建群聊。</small
           >
           <small v-if="dialog === 'editGroup'">可增减成员,最多 8 位。当前管理员不能移除;如需移除,请先更换管理员并保存。</small>

+ 6 - 1
src/runner/agent.ts

@@ -50,7 +50,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1':'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'computer-worker-v1,artifact-chunks-v1,helper-retirement-v1':'',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok)throw new HttpError(response.status,`执行节点连接被拒绝(HTTP ${response.status})`,response.status===403?'runner_unauthorized':'runner_request_failed')
@@ -86,6 +86,11 @@ export class RunnerAgent {
   private async execute(command:RunnerCommand):Promise<any> {
     const p=command.payload
     if(!this.active||!this.connected)throw new Error('执行节点已断开')
+    if(command.kind==='computer.retire'){
+      const meta=p as unknown as ComputerTarget
+      if(!this.computers||![meta.environmentId,meta.agentId].every(value=>/^[0-9a-f-]{36}$/.test(value))||!/^[a-f0-9]{64}$/.test(meta.ownerKey))throw new HttpError(400,'电脑清理请求无效','computer_cleanup_invalid')
+      await this.computers.retire(meta);return {ok:true}
+    }
     if(command.kind==='http') {
       const path=String(p.path),search=new URLSearchParams(String(p.search??''))
       if(path==='/api/computer/capabilities'||(p.computer&&path==='/api/plugins/yaoyao-bot-bridge/capabilities')){

+ 11 - 1
src/runner/computers/container.ts

@@ -1,6 +1,6 @@
 import { createHash, randomBytes } from 'node:crypto'
 import { spawn } from 'node:child_process'
-import { mkdir, realpath, chmod } from 'node:fs/promises'
+import { mkdir, realpath, chmod, lstat, rm } from 'node:fs/promises'
 import { join, resolve, posix } from 'node:path'
 import { z } from 'zod'
 
@@ -205,6 +205,16 @@ export class ContainerComputerProvider implements ComputerProvider {
       }
     })
   }
+  async deleteWorkspace(value:ComputerSpecification):Promise<void>{
+    const spec=this.validateSpecification(value)
+    if(await this.inspectRaw(this.name(spec)))throw new ComputerError('computer_busy','删除工作区前必须移除电脑实例')
+    const base=await realpath(resolve(this.home,'computer-workspaces')).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    if(!base)return
+    const path=join(base,spec.id),info=await lstat(path).catch(error=>{if(error.code==='ENOENT')return undefined;throw error})
+    if(!info)return
+    if(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path)throw new ComputerError('computer_path_unsafe','拒绝删除非预期的电脑工作区')
+    await rm(path,{recursive:true})
+  }
   async openPipe(value:ComputerSpecification,argv:string[],authorize:()=>void){
     const spec=this.validateSpecification(value)
     authorize();const state=await this.inspect(spec);authorize()

+ 11 - 0
src/runner/computers/pool.ts

@@ -98,6 +98,17 @@ export class ComputerPool {
     this.invalidate(entry)
     return this.serial(entry.spec.id,async()=>{const current=this.get(entry.spec.id)!;if(current.status==='free')return;await this.stopEntry(current)})
   }
+  definition(ownerKey:string,id:string){const entry=this.get(id);if(entry&&entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配');return entry?.spec}
+  async retire(ownerKey:string,id:string):Promise<ComputerSpecification|undefined>{
+    return this.serial(id,async()=>{
+      const entry=this.get(id);if(!entry)return
+      if(entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')
+      if(entry.status!=='free')throw new ComputerError('computer_busy','电脑停止状态尚未确认')
+      await this.provider.remove(entry.spec)
+      this.db.prepare('DELETE FROM computer_environments WHERE id=?').run(id)
+      return entry.spec
+    })
+  }
   async stopHolder(ownerKey:string,id:string,holderId:string):Promise<void>{
     const entry=this.get(id)
     if(!entry||entry.spec.ownerKey!==ownerKey)throw new ComputerError('computer_owner_mismatch','电脑环境归属不匹配')

+ 23 - 2
src/runner/worker/gateway.ts

@@ -1,7 +1,7 @@
 import {randomUUID} from 'node:crypto'
 import {join,dirname,posix} from 'node:path'
 import {fileURLToPath} from 'node:url'
-import {mkdir} from 'node:fs/promises'
+import {mkdir,rm} from 'node:fs/promises'
 import {existsSync} from 'node:fs'
 import type {DatabaseSync} from 'node:sqlite'
 import {z} from 'zod'
@@ -32,6 +32,7 @@ export class ComputerRuntime {
   readonly pool:ComputerPool
   readonly ready:Promise<void>
   readonly config:NonNullable<RunnerConfiguration['computers']>
+  readonly workers=new Map<string,{environmentId:string;ownerKey:string;process:HermesWorkerProcess}>()
   readonly script:string
   readonly proxyScript:string
   constructor(readonly db:DatabaseSync,config:RunnerConfiguration,readonly home:string,script?:string){
@@ -41,9 +42,28 @@ export class ComputerRuntime {
     this.proxyScript=existsSync(join(dirname(this.script),'guest_proxy.py'))?join(dirname(this.script),'guest_proxy.py'):join(dirname(this.script),'../network/guest_proxy.py')
     this.provider=new ContainerComputerProvider(this.config.runtime,config.runnerId,home)
     this.pool=new ComputerPool(db,this.provider,{concurrent:this.config.maxConcurrent??2,environments:32,ttlMs:30000})
+    db.exec('CREATE TABLE IF NOT EXISTS retired_computers(id TEXT PRIMARY KEY,owner_key TEXT NOT NULL,spec TEXT)')
     db.exec('CREATE TABLE IF NOT EXISTS computer_sessions(id TEXT PRIMARY KEY,value TEXT NOT NULL)')
     this.ready=this.pool.recover();void this.ready.catch(()=>{})
   }
+  assertTarget(meta:ComputerTarget){if(this.db.prepare('SELECT id FROM retired_computers WHERE id=?').get(meta.environmentId))throw new HttpError(410,'临时电脑已退役','computer_retired')}
+  async retire(meta:ComputerTarget){
+    const old=this.db.prepare('SELECT owner_key,spec FROM retired_computers WHERE id=?').get(meta.environmentId) as {owner_key:string;spec?:string}|undefined
+    if(old&&old.owner_key!==meta.ownerKey)throw new HttpError(403,'电脑归属不匹配','computer_owner_mismatch')
+    this.pool.definition(meta.ownerKey,meta.environmentId)
+    this.db.prepare('INSERT OR IGNORE INTO retired_computers VALUES(?,?,NULL)').run(meta.environmentId,meta.ownerKey)
+    await Promise.all([...this.workers.values()].filter(worker=>worker.environmentId===meta.environmentId&&worker.ownerKey===meta.ownerKey).map(worker=>worker.process.close()))
+    const current=this.pool.status(meta.ownerKey).find(row=>row.environmentId===meta.environmentId)
+    if(current?.holderId)await this.pool.stopHolder(meta.ownerKey,meta.environmentId,current.holderId)
+    let spec=old?.spec?JSON.parse(old.spec):undefined
+    spec??=this.pool.definition(meta.ownerKey,meta.environmentId)
+    if(spec)this.db.prepare('UPDATE retired_computers SET spec=? WHERE id=?').run(JSON.stringify(spec),meta.environmentId)
+    await this.pool.retire(meta.ownerKey,meta.environmentId)
+    if(spec)await this.provider.deleteWorkspace(spec)
+    const sessions=(this.db.prepare('SELECT id,value FROM computer_sessions').all() as {id:string;value:string}[]).filter(row=>{const session=JSON.parse(row.value);return session.environmentId===meta.environmentId&&session.ownerKey===meta.ownerKey})
+    for(const row of sessions){if(!/^[0-9a-f-]{36}$/.test(row.id))throw new Error('电脑会话编号无效');await rm(join(this.home,'workers',row.id),{recursive:true,force:true});this.db.prepare('DELETE FROM computer_sessions WHERE id=?').run(row.id)}
+    this.db.prepare('UPDATE retired_computers SET spec=NULL WHERE id=?').run(meta.environmentId)
+  }
   save(session:WorkerSession){this.db.prepare('INSERT INTO computer_sessions VALUES(?,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(session.id,JSON.stringify(session))}
   session(id:string,meta:ComputerTarget,profile:string):WorkerSession {
     const row=this.db.prepare('SELECT value FROM computer_sessions WHERE id=?').get(id) as {value:string}|undefined
@@ -68,7 +88,7 @@ export class ComputerGateway {
   private checking=false
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
   constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>){ }
-  async connect(){await this.runtime.ready;await this.authorized()}
+  async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized()}
   private async authorized(){try{await this.check();this.guard()}catch(error){await this.interrupt().catch(()=>{});if(!this.closed)this.onDisconnect();throw error}}
   private guard(){if(this.closed||this.live?.controller.signal.aborted)throw new HttpError(410,'电脑任务授权已结束','computer_cancelled')}
   private event(type:string,payload:Record<string,unknown>){if(!this.closed&&this.live)this.onEvent({type,session_id:this.live.session.id,payload})}
@@ -132,6 +152,7 @@ export class ComputerGateway {
       await mkdir(directory,{recursive:true,mode:0o700})
       const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
       live.worker=worker
+      this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>this.runtime.workers.delete(this.workId))
       worker.onTool=(name,args,id)=>this.tool(live,name,args,id)
       worker.onEvent=frame=>{
         if(frame.type==='delta')this.event('message.delta',{text:frame.text})

+ 1 - 0
src/server/app.ts

@@ -210,6 +210,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
   workspaceNodes.runnerTarget=(owner,nodeId,computer)=>runners.target(owner,nodeId,computer)
   const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner), owner => auth.pushAuthorizationVersion(owner) ?? 0)
+  workspaceRuntime.retireHelper=(owner,helper)=>runners.retireHelper(owner,helper)
   workspaceRuntime.onTeamCreated = (owner, team) => {
     try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
   }

+ 11 - 2
src/server/runnerHub.ts

@@ -97,7 +97,7 @@ export class RunnerHub {
       return {status:result.status,headers:new Headers(result.headers),body:Buffer.from(result.body,'base64')}
     }
     return {url:this.local.url,client:this.local.client,session:{request:requestHTTP,webSocketCredential:async()=>{throw new Error('Runner uses its own authenticated gateway')}},
-      runner:{id:record.id,computer:!!computer,
+      runner:{id:record.id,computer:!!computer,helperRetirement:this.online.get(record.id)?.features.includes('helper-retirement-v1')===true,
         open:async(onEvent,onDisconnect,scope)=>{
           requireComputer()
           if(computer&&!scope)throw new HttpError(403,'隔离执行缺少任务授权','computer_scope_required')
@@ -134,6 +134,15 @@ export class RunnerHub {
         },
       }}
   }
+  async retireHelper(owner:string,helper:import('../shared/workspace.js').WorkspaceAgent):Promise<void>{
+    if(!helper.temporaryGoalId||!helper.archived||!helper.helperRunnerId)throw new HttpError(403,'只能清理已退役的任务助手','helper_cleanup_forbidden')
+    const current=this.store.require<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',helper.id)
+    if(!current.archived||current.temporaryGoalId!==helper.temporaryGoalId)throw new HttpError(409,'助手状态已改变','helper_cleanup_forbidden')
+    const record=this.store.require<RunnerRecord>('_system','runner',helper.helperRunnerId)
+    if(record.sourceNodeId!==helper.nodeId||(record.sourceOwner!=='_system'&&record.sourceOwner!==owner))throw new HttpError(403,'清理节点不匹配','helper_cleanup_forbidden')
+    if(!this.online.get(record.id)?.features.includes('helper-retirement-v1'))throw new HttpError(503,'等待支持清理的原执行节点上线','runner_offline')
+    await this.request(record.id,'computer.retire',{environmentId:helper.id,agentId:helper.id,ownerKey:hash(owner)})
+  }
   adminRouter():Router {
     const router=new Router()
     router.get('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body={protocol:RUNNER_PROTOCOL,runners:this.records().map(r=>this.summary(r)),sources:[{id:'local',name:'本地来源'},...this.store.list<import('./workspaceGateway.js').WorkspaceNode>(actor.id,'node').filter(n=>n.transport!=='paired-web').map(n=>({id:n.id,name:n.name}))]}})
@@ -160,7 +169,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(match[2]==='poll')state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(match[2]==='poll')state.features=ctx.get('x-runner-features').split(',').filter(value=>['computer-worker-v1','artifact-chunks-v1','helper-retirement-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')

+ 30 - 7
src/server/taskCoordinator.ts

@@ -39,6 +39,8 @@ function requestId(value: string): string {
 }
 
 export class WorkspaceTaskCoordinator {
+  private cleaning=new Set<string>()
+  private cleanupAfter=new Map<string,number>()
   private pending = false
   private closed = false
   private unobserve: () => void
@@ -106,7 +108,7 @@ export class WorkspaceTaskCoordinator {
     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)
+    if (!this.store.taskMemberIds(owner,team,goal.id).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')
@@ -115,7 +117,7 @@ export class WorkspaceTaskCoordinator {
       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)
+      if(goal.status!=='waiting')goal.status = 'running';goal.revision++;this.saveGoal(owner, goal)
       return assignment
     })
   }
@@ -134,7 +136,7 @@ export class WorkspaceTaskCoordinator {
       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)
+      if(goal.status!=='waiting')goal.status = 'running';goal.revision++;this.saveGoal(owner, goal)
       return assignment
     })
   }
@@ -157,7 +159,7 @@ export class WorkspaceTaskCoordinator {
         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')
+        if(target.archived||target.id===actorId||!this.store.taskMemberIds(owner,team,goal.id).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)
@@ -253,11 +255,32 @@ export class WorkspaceTaskCoordinator {
     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. */ }
+      for (const owner of this.store.owners()){
+        for (const goal of this.store.list<AgentGoal>(owner, 'goal')) {
+          try { this.reconcile(owner, goal);this.retireHelpers(owner,goal) } catch { /* The next committed event retries durable state. */ }
+        }
+        for(const goalId of new Set(this.store.list<Agent>(owner,'agent').flatMap(agent=>agent.temporaryGoalId?[agent.temporaryGoalId]:[])))if(!this.store.get(owner,'goal',goalId)||!this.store.get(owner,'conversation-task',goalId))this.retireHelpers(owner,{id:goalId,status:'cancelled',activation:0})
       }
     })
   }
+  private retireHelpers(owner:string,goal:Pick<AgentGoal,'id'|'status'|'activation'>):void {
+    for(const helper of this.store.list<Agent>(owner,'agent').filter(agent=>agent.temporaryGoalId===goal.id)){
+      if(!helper.archived&&!terminalGoal(goal.status)&&helper.helperActivation===(goal.activation??1))continue
+      if(!helper.archived){helper.archived=true;helper.revision++;helper.updatedAt=Date.now();helper.retiredAt=Date.now();helper.cleanupState='pending';this.store.put(owner,'agent',helper.id,helper);this.store.event(owner,'agent.changed',helper)}
+      if(!helper.cleanupState){helper.cleanupState='pending';helper.retiredAt??=Date.now();this.store.put(owner,'agent',helper.id,helper)}
+      const works=this.store.list<{agentId:string;runId:string;status:string}>(owner,'turn').filter(work=>work.agentId===helper.id&&!terminalRun(work.status))
+      if(works.length){for(const runId of new Set(works.map(work=>work.runId)))void this.runtime.stop(owner,runId).catch(()=>{});continue}
+      if(helper.cleanupState==='complete')continue
+      const key=`${owner}:${helper.id}`
+      if(this.cleaning.has(key)||(this.cleanupAfter.get(key)??0)>Date.now())continue
+      this.cleaning.add(key)
+      void this.runtime.retireHelper(owner,helper).then(()=>{
+        if(this.closed)return
+        const current=this.store.require<Agent>(owner,'agent',helper.id)
+        current.cleanupState='complete';current.retiredAt??=Date.now();this.store.put(owner,'agent',current.id,current);this.store.event(owner,'agent.changed',current)
+      }).catch(()=>{this.cleanupAfter.set(key,Date.now()+5000)}).finally(()=>this.cleaning.delete(key))
+    }
+  }
   private reconcile(owner: string, goal: AgentGoal): void {
     if(goal.status==='cancelling') {
       const all=this.store.list<AgentGoal>(owner,'goal'), owned=new Set([goal.id])
@@ -312,7 +335,7 @@ export class WorkspaceTaskCoordinator {
         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)) {
+        if (!target || target.archived || !this.store.taskMemberIds(owner,team,goal.id).includes(target.id)) {
           assignment.status = 'blocked';assignment.result = '负责成员已不可用';this.saveAssignment(owner, assignment);continue
         }
         try { this.runtime.nodes.requireSource(owner, target) }

+ 3 - 1
src/server/workspaceGateway.ts

@@ -29,6 +29,7 @@ export interface GatewayTarget {
   runner?: {
     id: string
     computer?:boolean
+    helperRetirement?:boolean
     open(onEvent:(frame:GatewayFrame)=>void,onDisconnect:()=>void,scope?:GatewayExecutionScope):Promise<{rpc(method:string,params:Record<string,unknown>):Promise<any>;close():void}>
     lease(input:import('./workspaceToolLease.js').LeaseInput):Promise<import('./workspaceToolLease.js').WorkspaceToolLease>
   }
@@ -104,10 +105,11 @@ export class WorkspaceNodes {
     }
     return target
   }
-  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string }): GatewayTarget {
+  targetForAgent(owner: string, agent: { id?:string;nodeId: string; remoteAgentId?: string;execution?:string;helperRunnerId?:string }): GatewayTarget {
     if(agent.execution==='computer'){
       if(!agent.id||agent.remoteAgentId)throw new HttpError(409,'隔离电脑需要当前服务器管理的 Agent','computer_agent_required')
       const target=this.runnerTarget?.(owner,agent.nodeId,{environmentId:agent.id,agentId:agent.id,ownerKey:createHash('sha256').update(owner).digest('hex')})
+      if(agent.helperRunnerId&&target?.runner?.id!==agent.helperRunnerId)throw new HttpError(409,'临时助手的原执行节点已变化,请创建新助手','helper_runner_changed')
       if(!target)throw new HttpError(409,'隔离电脑需要连接执行节点','computer_runner_required')
       return target
     }

+ 7 - 4
src/server/workspaceRuntime.ts

@@ -62,6 +62,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
   private live = new Map<string, LiveTurn>()
   readonly teamTools: WorkspaceTeamTools
   readonly tasks: WorkspaceTaskCoordinator
+  retireHelper:(owner:string,helper:Agent)=>Promise<void>=async()=>{throw new Error('电脑清理服务未连接')}
   publishArtifact:(owner:string,message:Message,agent:Agent,name:string,bytes:Buffer)=>import('../shared/workspace.js').WorkspaceFile=()=>{throw new Error('产物服务未初始化')}
   onTeamCreated: (owner: string, team: Conversation) => void = () => {}
   constructor(store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly uploads: UploadStore, userActive: (owner: string) => boolean = () => true,
@@ -98,12 +99,12 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         if (!alreadyActive && this.store.activeTaskCount(owner, conversationId) >= 4)
           throw new HttpError(409, '最多同时运行 4 个任务', 'workspace_task_concurrency_limit')
       }
-      if (body.mentionIds.some((a) => !c.memberIds.includes(a)))
+      if (body.mentionIds.some((a) => !this.store.taskMemberIds(owner,c,conversationTaskId).includes(a)))
         throw new HttpError(400, '只能 @ 群内成员', 'invalid_mentions')
       const records = this.files(owner,body.fileIds)
       const now = Date.now(),
         runId = randomUUID(),
-        agents = c.memberIds.map((id) => this.store.require<Agent>(owner, 'agent', id))
+        agents = this.store.taskMemberIds(owner,c,conversationTaskId).map((id) => this.store.require<Agent>(owner, 'agent', id))
       const mentions = [
         ...new Set(
           [...body.mentionIds, ...mentionedAgents(body.content, agents)],
@@ -171,6 +172,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     recovering = false,
   ): Promise<Message> {
     this.requireAuthorization(owner, run.runId)
+    this.store.requireAgentTask(owner,agent,c.id,run.conversationTaskId)
     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),
@@ -184,7 +186,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     },authorize:()=>{
       this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,agent)
       const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id),conversation=this.store.require<Conversation>(owner,'conversation',c.id)
-      if(this.closing||conversation.archived||!conversation.memberIds.includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮 Agent 或任务授权已结束','run_authorization_revoked')
+      if(this.closing||conversation.archived||!this.store.taskMemberIds(owner,conversation,run.conversationTaskId).includes(agent.id)||this.store.require<Run>(owner,'run',run.runId).stopRequested||current.cancelRequested||['interrupted','complete','failed'].includes(current.status)||latest.archived||(latest.execution??'profile')!==(agent.execution??'profile')||latest.teamAuthorizationVersion!==agent.teamAuthorizationVersion)throw new HttpError(403,'本轮 Agent 或任务授权已结束','run_authorization_revoked')
     }})
     let binding = this.store.get<WorkspaceBinding>(owner, 'binding', key)
     const movedRunner=!!binding&&(binding.runnerId!==target.runner?.id||(binding.execution??'profile')!==(agent.execution??'profile'))
@@ -583,6 +585,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           '角色规则不赋予额外工具权限;仍遵守基础 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 记录完成、受阻或等待用户。不要将一次模型回复结束当作目标完成。'}` : '',
+          agent.temporaryGoalId ? `你是当前任务的临时助手,任务 ID:${agent.temporaryGoalId}。仅处理分派工作,使用 computer_export 回传产物。任务结束后会退役;不要创建团队或改变自身权限。` : '',
           toolLease ? TEAM_TOOL_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
         ]
@@ -591,7 +594,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         const text = c.kind === 'group' ? this.contextText(owner, c, agent, run, binding.contextSeq ?? 0, movedRunner)
           : movedRunner ? `执行节点已切换。以下是原会话的近期记录,旧路径和执行状态需要在当前节点重新核实;不要重新执行已完成的操作。\n\n${this.contextText(owner,c,agent,run,0,true)}` : trigger.content
         const admission = this.getWork(owner, run.id)
-        if (!this.store.require<Conversation>(owner, 'conversation', c.id).memberIds.includes(agent.id)) {
+        if (!this.store.taskMemberIds(owner,this.store.require<Conversation>(owner,'conversation',c.id),run.conversationTaskId).includes(agent.id)) {
           admission.status = 'interrupted'; admission.error = '执行前成员已移除'; this.saveWork(owner, admission)
           throw new Error('执行前成员已移除')
         }

+ 5 - 4
src/server/workspaceScheduler.ts

@@ -233,7 +233,7 @@ export abstract class WorkspaceScheduler {
         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))) {
+        if (work.status === 'queued' && (c.archived || root.stopRequested || !this.store.taskMemberIds(owner,c,work.conversationTaskId).includes(work.agentId))) {
           work.status = 'interrupted'; work.error = '执行前成员已移除或聊天已停止'; this.saveWork(owner, work); this.wake(); continue
         }
         const occupied = all.map(entry => this.getWork(entry.owner, entry.work.id)).filter(t => (['running', 'waiting', 'uncertain'].includes(t.status) || this.executing.has(t.id)) && t.id !== work.id)
@@ -254,7 +254,7 @@ export abstract class WorkspaceScheduler {
         if (!recovering) {
           work.status = 'running';work.resourceWait=false
           if (c.kind === 'group' && c.mode === 'host' && work.agentId === c.administratorId) { work.requiredReply = true; work.replyMode = 'automatic' }
-          work.turnConfiguration = { mode: c.mode, administratorId: c.administratorId, members: c.memberIds.map(id => { const a = this.store.require<Agent>(owner, 'agent', id); return { id, name: a.name } }) }
+          work.turnConfiguration = { mode: c.mode, administratorId: c.administratorId, members: this.store.taskMemberIds(owner,c,work.conversationTaskId).map(id => { const a = this.store.require<Agent>(owner, 'agent', id); return { id, name: a.name } }) }
           this.saveWork(owner, work)
         }
         void this.executeWork(owner, work, recovering).catch(() => this.retrySoon())
@@ -313,8 +313,9 @@ export abstract class WorkspaceScheduler {
   }
   async stopAgent(owner: string, conversationId: string, agentId: string, conversationTaskId?: string): Promise<void> {
     const c = this.store.require<Conversation>(owner, 'conversation', conversationId)
-    if (!c.memberIds.includes(agentId)) throw new HttpError(400, '只能停止群内成员', 'invalid_members')
-    const scopedTaskId = c.kind === 'group' ? this.store.resolveTask(owner, conversationId, conversationTaskId)!.id : undefined
+    const helper=this.store.get<Agent>(owner,'agent',agentId)
+    const scopedTaskId = c.kind === 'group' ? this.store.resolveTask(owner, conversationId, conversationTaskId??helper?.temporaryGoalId)!.id : undefined
+    if (!this.store.taskMemberIds(owner,c,scopedTaskId).includes(agentId)) throw new HttpError(400, '只能停止当前任务成员', 'invalid_members')
     await this.cancelWorks(owner, this.works(owner).filter(w => w.conversationId === conversationId && w.agentId === agentId && w.conversationTaskId === scopedTaskId))
   }
   async stopTask(owner: string, conversationId: string, conversationTaskId: string): Promise<void> {

+ 21 - 4
src/server/workspaceStore.ts

@@ -276,7 +276,19 @@ export class WorkspaceStore {
       return result
     })
   }
-  createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string }): Agent {
+  taskMemberIds(owner:string,conversation:Conversation,taskId?:string):string[]{
+    const base=conversation.memberIds.filter(id=>!this.get<Agent>(owner,'agent',id)?.temporaryGoalId)
+    if(!taskId)return base
+    const goal=this.get<import('../shared/agentTasks.js').AgentGoal>(owner,'goal',taskId)
+    if(!goal||goal.conversationId!==conversation.id||['complete','blocked','cancelled','cancelling'].includes(goal.status))return base
+    return [...new Set([...base,...this.list<Agent>(owner,'agent').filter(agent=>!agent.archived&&agent.temporaryGoalId===taskId&&agent.helperActivation===(goal.activation??1)).map(agent=>agent.id)])]
+  }
+  requireAgentTask(owner:string,agent:Agent,conversationId:string,taskId?:string):void {
+    if(!agent.temporaryGoalId)return
+    const conversation=this.require<Conversation>(owner,'conversation',conversationId)
+    if(agent.temporaryGoalId!==taskId||!this.taskMemberIds(owner,conversation,taskId).includes(agent.id))throw new HttpError(403,'临时助手只能执行所属任务','helper_task_bound')
+  }
+  createAgent(owner: string, input: unknown, origin?: { createdByAgentId: string; createdFromRunId: string;temporaryGoalId?:string;helperActivation?:number;helperRunnerId?:string }): Agent {
     const body = parse(agentInput, input)
     if (origin) this.require<Agent>(owner, 'agent', origin.createdByAgentId)
     return this.atomic(() => {
@@ -300,6 +312,7 @@ export class WorkspaceStore {
         updatedAt: now,
       }
       this.put(owner, 'agent', id, agent)
+      if(origin?.temporaryGoalId){this.event(owner,'agent.changed',agent);return agent}
       const conversation: Conversation = {
         id: randomUUID(),
         kind: 'direct',
@@ -330,6 +343,7 @@ export class WorkspaceStore {
     if (patch.avatar !== undefined) patch.avatar = normalizeAvatar(patch.avatar)
     return this.atomic(() => {
       const agent = this.require<Agent>(owner, 'agent', id)
+      if(agent.temporaryGoalId&&(Object.keys(patch).some(key=>key!=='archived')||patch.archived===false))throw new HttpError(409,'临时助手由所属任务管理,不能修改权限或恢复为持久成员','helper_task_bound')
       if(patch.execution&&patch.execution!==(agent.execution??'profile')&&this.list<{agentId:string;status:string}>(owner,'turn').some(work=>work.agentId===id&&['running','waiting','uncertain'].includes(work.status)))throw new HttpError(409,'请先停止当前任务,再切换执行环境','agent_execution_busy')
       if (agent.remoteAgentId && Object.keys(patch).some(key => key !== 'archived'))
         throw new HttpError(409, '引用 Agent 的配置由远端管理', 'remote_agent_read_only')
@@ -365,9 +379,11 @@ export class WorkspaceStore {
       Object.keys(body.memberRoles).some(id => !ids.has(id))
     )
       throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
-    for (const id of ids)
-      if (this.require<Agent>(owner, 'agent', id).archived)
-        throw new HttpError(409, '已归档 Agent 不能加入新群', 'agent_archived')
+    for (const id of ids){
+      const agent=this.require<Agent>(owner,'agent',id)
+      if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入持久团队','helper_task_bound')
+      if(agent.archived)throw new HttpError(409,'已归档 Agent 不能加入新群','agent_archived')
+    }
     const now = Date.now()
     const c: Conversation = {
       ...body,
@@ -627,6 +643,7 @@ export class WorkspaceStore {
       throw new HttpError(400, '群成员或管理员无效', 'invalid_members')
     for (const memberId of memberIds) {
       const agent = this.require<Agent>(owner, 'agent', memberId)
+      if(agent.temporaryGoalId)throw new HttpError(409,'临时助手不能加入其他群聊','helper_task_bound')
       if (!c.memberIds.includes(memberId) && agent.archived)
         throw new HttpError(409, '已归档 Agent 不能加入群聊', 'agent_archived')
     }

+ 26 - 3
src/server/workspaceTeamTools.ts

@@ -12,6 +12,7 @@ import { assignmentInput, assignmentReview, assignmentUpdate, assignmentCancel,
 const uuid = z.string().uuid()
 const empty = z.object({}).strict()
 const createAgent = agentInput.omit({ canManageTeam: true }).extend({ requestId: uuid,execution:z.enum(['profile','computer']).optional() }).strict()
+const createHelper=agentInput.pick({name:true,instructions:true}).extend({requestId:uuid,goalId:uuid,profile:z.string().min(1).max(256).optional(),title:z.string().trim().min(1).max(100),brief:z.string().trim().min(1).max(16000),acceptanceCriteria:z.array(z.string().trim().min(1).max(1000)).max(12).default([]),dependsOn:z.array(uuid).max(12).default([])}).strict()
 const createTeam = z.object({
   requestId: uuid,
   name: z.string().trim().min(1).max(100),
@@ -36,6 +37,7 @@ const definitions = [
   ['workspace_list_agents', '查看当前账号可复用的成员,优先复用已有 Agent,避免重复创建。', empty],
   ['workspace_list_teams', '查看由你担任管理员的团队及其任务。', empty],
   ['workspace_create_agent', '创建持久保存的 Bot Agent,复用已授权的基础 Profile。新成员不获得组队权限。requestId 使用新的 UUID;重试同一操作必须复用它。', createAgent],
+  ['workspace_create_helper','为当前团队目标创建临时隔离助手并立即分派一个具体子任务。助手只参与此任务、不创建单聊、不获得组队权限;目标结束后退役。每个目标最多 8 个有效临时助手。优先用于一次性工作。',createHelper],
   ['workspace_create_team', '建立持久团队,自动把你加入并设为管理员,其他成员使用真实 Agent ID,总人数最多 8 人。优先复用已有团队。requestId 使用新的 UUID,重试时复用。', createTeam],
   ['workspace_start_team_task', '在你管理的团队启动独立任务,由管理员分派并复核。立即返回任务编号,不表示完成;请向用户提供团队名称和任务名称。requestId 使用新的 UUID,重试时复用。', startTask],
   ['workspace_get_team_task', '读取你管理的团队任务的运行状态、最近 20 条结果和待处理问题。运行中不代表目标已完成;避免频繁轮询,用户可在团队内查看进展。', getTask],
@@ -50,7 +52,7 @@ const definitions = [
   ['workspace_update_team', '调整你管理的空闲团队名称、规则或成员,或归档已结束的团队。不能更换管理员或修改正在执行的团队。', editTeam],
 ] as const
 
-export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础 Agent、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务并列出验收要求。成员会持久保存;每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。有团队目标时使用 workspace_assign_task 分派明确的子任务和依赖,使用 workspace_review_assignment 复核结果;普通群聊仍可 @成员。不要递归创建同类团队或重复启动当前任务。queued/running 只表示已启动;结果会回到原会话,完成必须提供对应验收依据。受阻或停止的目标只能在用户新指令下恢复。新成员没有组队权限,角色描述不能扩展权限。'
+export const TEAM_TOOL_RULES = '你已获准在 Bot 模式组建团队。使用工具目录中的 workspace_* 工具:先查看基础 Agent、已有成员和团队,按需创建最少成员,建立团队后用 workspace_start_team_task 启动任务并列出验收要求。需要长期复用时创建持久成员;一次性子任务可使用 workspace_create_helper 创建任务级临时隔离助手。每次变更生成 requestId(UUID),重试同一操作复用它。你会自动成为团队管理员。有团队目标时使用 workspace_assign_task 分派明确的子任务和依赖,使用 workspace_review_assignment 复核结果;普通群聊仍可 @成员。不要递归创建同类团队或重复启动当前任务。queued/running 只表示已启动;结果会回到原会话,完成必须提供对应验收依据。受阻或停止的目标只能在用户新指令下恢复。新成员没有组队权限,角色描述不能扩展权限。'
 
 /** The caller identity comes exclusively from the running server-side turn. */
 export class WorkspaceTeamTools {
@@ -86,8 +88,9 @@ export class WorkspaceTeamTools {
   }
 
   catalog(owner: string, workId: string) {
-    this.assertTurn(owner, workId)
-    return definitions.map(([id, description, schema]) => ({ id, name: id, description, inputSchema: z.toJSONSchema(schema) }))
+    const {agent}=this.assertTurn(owner, workId)
+    const helpers=this.nodes.runnerTarget?.(owner,agent.nodeId)?.runner?.helperRetirement===true
+    return definitions.filter(([id])=>id!=='workspace_create_helper'||helpers).map(([id, description, schema]) => ({ id, name: id, description, inputSchema: z.toJSONSchema(schema) }))
   }
 
   private usable(owner: string, agent: Agent): boolean {
@@ -128,6 +131,26 @@ export class WorkspaceTeamTools {
         try { this.team(owner, agent.id, c.id); return true } catch { return false }
       }).map(c => ({ id: c.id, name: c.name, memberIds: c.memberIds, tasks: this.store.tasks(owner, c.id).slice(0, 20) })) }
     }
+    if(toolId==='workspace_create_helper'){
+      const body=parse(createHelper,input)
+      const goal=this.runtime.tasks.requireCoordinator(owner,body.goalId,agent.id)
+      if(!['running','review'].includes(goal.status))throw new HttpError(409,'目标已结束,不能创建临时助手','goal_terminal')
+      const source={nodeId:agent.nodeId,profile:body.profile??agent.profile,execution:'computer' as const}
+      await this.requireAvailable(owner,source);this.assertTurn(owner,workId)
+      const runner=this.nodes.runnerTarget?.(owner,source.nodeId)?.runner,runnerId=runner?.id
+      if(!runnerId||!runner.helperRetirement)throw new HttpError(409,'临时助手需要电脑执行节点','computer_runner_required')
+      return this.store.command(owner,body.requestId,{agentId:agent.id,toolId,...body},()=>{
+        const current=this.runtime.tasks.requireCoordinator(owner,goal.id,agent.id)
+        if(current.activation!==goal.activation||!['running','review'].includes(current.status))throw new HttpError(409,'任务已变化,请重新读取状态','goal_changed')
+        const all=this.store.list<Agent>(owner,'agent'),helpers=all.filter(item=>item.temporaryGoalId===goal.id)
+        if(helpers.filter(item=>!item.archived).length>=8||helpers.length>=32)throw new HttpError(409,'临时助手已达任务上限,请复用现有助手','helper_limit')
+        let name=body.name,index=2
+        while(all.some(item=>item.name.toLocaleLowerCase()===name.toLocaleLowerCase()))name=`${body.name.slice(0,80)} · 临时${index++}`
+        const helper=this.store.createAgent(owner,{...source,name,instructions:body.instructions,canManageTeam:false},{createdByAgentId:agent.id,createdFromRunId:work.runId,temporaryGoalId:goal.id,helperActivation:goal.activation??1,helperRunnerId:runnerId})
+        const assignment=this.runtime.tasks.createAssignment(owner,agent.id,{requestId:randomUUID(),goalId:goal.id,agentId:helper.id,title:body.title,brief:body.brief,acceptanceCriteria:body.acceptanceCriteria,dependsOn:body.dependsOn})
+        return {helper:this.store.agentSummary(helper),assignment}
+      })
+    }
     if (toolId === 'workspace_create_agent') {
       const { requestId, ...body } = parse(createAgent, input)
       body.execution??=agent.execution??'profile'

+ 1 - 1
src/shared/runner.ts

@@ -11,7 +11,7 @@ export interface RunnerRecord {
 }
 export interface RunnerCommand {
   id: string
-  kind: 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
+  kind: 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
   payload: Record<string, unknown>
   expiresAt: number
 }

+ 5 - 0
src/shared/workspace.ts

@@ -8,6 +8,11 @@ export interface WorkspaceAgent {
   execution?: 'profile' | 'computer'
   canManageTeam?: boolean
   teamAuthorizationVersion?: number
+  temporaryGoalId?:string
+  helperActivation?:number
+  helperRunnerId?:string
+  retiredAt?:number
+  cleanupState?:'pending'|'complete'
   createdByAgentId?: string
   createdFromRunId?: string
   nodeId: string

+ 3 - 1
tests/e2e/team-tools.spec.ts

@@ -42,7 +42,7 @@ test('task plans show owners and keep independent text and attachment drafts whe
   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:{}})
+  const seeded=await page.request.post('/__test/task-plan?helper=1',{data:{}})
   expect(seeded.ok()).toBe(true)
   const ids=await seeded.json()
   await page.goto(`/conversations/${ids.sourceConversationId}`)
@@ -51,6 +51,7 @@ test('task plans show owners and keep independent text and attachment drafts whe
   await page.locator('details.task-plan summary').click()
   await expect(page.locator('.task-plan')).toContainText('研究资料')
   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('待发送附件')})
@@ -64,6 +65,7 @@ test('task plans show owners and keep independent text and attachment drafts whe
   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('已停止')
+  await expect(page.locator('.task-plan')).toContainText('已退役')
 })
 
 test('registers an execution node, downloads its private config and disables it',async({page},testInfo)=>{

+ 2 - 1
tests/fixtures/workspace-server.ts

@@ -342,7 +342,8 @@ runtime.app.use((ctx,next) => {
   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()}
+  const temporary=ctx.query.helper==='1'?runtime.workspace.createAgent(owner,{name:'验收研究员(临时)',profile:'default',execution:'computer'},{createdByAgentId:lead.id,createdFromRunId:randomUUID(),temporaryGoalId:goal.id,helperActivation:goal.activation??1,helperRunnerId:randomUUID()}):undefined
+  const assignment={id:randomUUID(),goalId:goal.id,conversationId:team.id,agentId:temporary?.id??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()})

+ 23 - 3
tests/live/worker.live.test.ts

@@ -60,11 +60,11 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     if(req.headers.authorization!=='Bearer fixture-private-key'){res.statusCode=401;res.end('{}');return}
     commands.push(input)
     const user=input.messages.filter((message:any)=>message.role==='user').at(-1)?.content??''
-    const team=String(user).includes('fixture:team'),slow=String(user).includes('fixture:slow')
+    const helper=/fixture:helper ([0-9a-f-]{36})/.exec(String(user)),team=String(user).includes('fixture:team'),slow=String(user).includes('fixture:slow')
     const current=input.messages.slice(input.messages.findLastIndex((message:any)=>message.role==='user')+1)
     const called=current.some((message:any)=>message.role==='tool')
     const exported=current.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
-    const message=called&&!team&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:slow?'sleep 30; printf forbidden > late-write.txt':(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'curl --fail --silent --show-error --max-time 20 https://example.com > public.html; ':'')+'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
+    const message=called&&!team&&!helper&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:helper?'workspace_create_helper':team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(helper?{requestId:randomUUID(),goalId:helper[1],name:'临时核验助手',title:'临时核验',brief:'在隔离电脑完成 fixture:helper-work 并导出结果'}:team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:slow?'sleep 30; printf forbidden > late-write.txt':(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'curl --fail --silent --show-error --max-time 20 https://example.com > public.html; ':'')+'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
     if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
     else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
   })
@@ -123,5 +123,25 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
     await expect.poll(()=>app.workspace.require<any>(owner,'run',managed.id).status,{timeout:45000}).toMatch(/complete|failed/)
     expect(app.workspace.require<any>(owner,'run',managed.id).status,JSON.stringify(app.workspace.messages(owner,managerChat.id))).toBe('complete')
     expect(app.workspace.list<any>(owner,'agent').find(agent=>agent.name==='隔离创建成员')).toMatchObject({execution:'computer',canManageTeam:false,createdByAgentId:manager.id})
+    const group=app.workspace.createGroup(owner,{name:'临时助手验收团队',memberIds:[manager.id,worker.id],administratorId:manager.id})
+    const task=app.workspace.tasks(owner,group.id)[0]!
+    const goal=app.workspaceRuntime.tasks.begin(owner,task,manager,'临时助手必须完成隔离工作并回传文件',{conversationId:managerChat.id,runId:managed.id,agentId:manager.id})
+    const helperRun=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:`fixture:helper ${goal.id}`})
+    await expect.poll(()=>app.workspace.require<any>(owner,'run',helperRun.id).status,{timeout:45000}).toMatch(/complete|failed/)
+    expect(app.workspace.require<any>(owner,'run',helperRun.id).status).toBe('complete')
+    const temporary=app.workspace.list<any>(owner,'agent').find(agent=>agent.temporaryGoalId===goal.id)!
+    expect(temporary).toMatchObject({execution:'computer',canManageTeam:false,helperRunnerId:registered.runner.id})
+    expect(app.workspace.list<any>(owner,'conversation').some(conversation=>conversation.kind==='direct'&&conversation.memberIds.includes(temporary.id))).toBe(false)
+    await expect.poll(()=>app.workspaceRuntime.tasks.assignments(owner,goal.id)[0]?.status,{timeout:45000}).toMatch(/review|failed|blocked/)
+    const assignment=app.workspaceRuntime.tasks.assignments(owner,goal.id)[0]!
+    expect(assignment.status,assignment.result).toBe('review')
+    expect(assignment.artifactIds.length).toBeGreaterThan(0)
+    app.workspaceRuntime.tasks.reviewAssignment(owner,manager.id,{requestId:randomUUID(),assignmentId:assignment.id,decision:'accept',review:'已核对隔离执行和真实产物'})
+    app.workspaceRuntime.tasks.finish(owner,manager.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'临时助手执行与产物均已核验',checks:[{criterion:0,passed:true,evidence:'隔离执行返回了真实文件,文件已保存到当前任务'}]})
+    await expect.poll(()=>app.workspace.require<any>(owner,'agent',temporary.id).cleanupState,{timeout:45000}).toBe('complete')
+    expect(app.workspace.require<any>(owner,'agent',temporary.id).archived).toBe(true)
+    await expect(access(join(runnerHome,'computer-workspaces',temporary.id))).rejects.toMatchObject({code:'ENOENT'})
+    expect(app.workspace.list<any>(owner,'file').filter(file=>assignment.artifactIds.includes(file.id)).length).toBe(assignment.artifactIds.length)
+
   }finally{abort.abort();await running?.catch(()=>{});app.close();await Promise.all([server,upstream].map(server=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})));await rm(home,{recursive:true,force:true})}
-},120000)
+},180000)

+ 33 - 0
tests/server/helperCleanup.test.ts

@@ -0,0 +1,33 @@
+// @vitest-environment node
+import {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 {DatabaseSync} from 'node:sqlite'
+import {ComputerRuntime} from '../../src/runner/worker/gateway'
+it('rejects foreign retirement and resumes failed workspace cleanup from its durable record',async()=>{
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-helper-cleanup-')),db=new DatabaseSync(':memory:')
+  const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}},home)
+  try{
+    await runtime.ready
+    vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id:'fixture',containerId:'fixture',running:true,workspace:'/fixture',isolation:'container'})
+    const stop=vi.spyOn(runtime.provider,'stop').mockResolvedValue(undefined)
+    vi.spyOn(runtime.provider,'remove').mockResolvedValue(undefined)
+    const remove=vi.spyOn(runtime.provider,'deleteWorkspace').mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValue(undefined)
+    const meta={environmentId:randomUUID(),agentId:randomUUID(),ownerKey:'owner'}
+    const spec={id:meta.environmentId,ownerKey:meta.ownerKey,imageId:runtime.config.imageId}
+    const lease=await runtime.pool.acquire(spec,'task',()=>{})
+    await expect(runtime.retire({...meta,ownerKey:'foreign'})).rejects.toMatchObject({code:'computer_owner_mismatch'})
+    expect(stop).not.toHaveBeenCalled()
+    await runtime.pool.release(lease)
+    await expect(runtime.retire(meta)).rejects.toThrow('storage unavailable')
+    expect(runtime.pool.definition(meta.ownerKey,meta.environmentId)).toBeUndefined()
+    expect(()=>runtime.assertTarget(meta)).toThrow('临时电脑已退役')
+    await runtime.retire(meta)
+    expect(remove).toHaveBeenCalledTimes(2)
+    expect(remove.mock.calls[1]![0]).toMatchObject(spec)
+    await runtime.retire(meta)
+    expect(remove).toHaveBeenCalledTimes(2)
+  }finally{await runtime.pool.close();db.close();rmSync(home,{recursive:true,force:true})}
+})

+ 30 - 0
tests/server/workspaceTeamTools.test.ts

@@ -266,3 +266,33 @@ it('isolated managers cannot create host members and a running Agent cannot chan
   await expect(call('create_agent',{requestId:randomUUID(),name:'host bypass',profile:'default',execution:'profile'})).rejects.toMatchObject({code:'computer_scope_escalation'})
   expect(()=>store.updateAgent(owner,manager.id,{execution:'profile'})).toThrow('请先停止当前任务')
 })
+it('creates a task-scoped helper once and rejects promotion or use outside its goal',async()=>{
+  const {team,task}=await buildTeam()
+  const goal=runtime.tasks.begin(owner,task,manager,'完成当前任务',{conversationId:work.conversationId,runId:work.runId,agentId:manager.id})
+  const runnerId=randomUUID()
+  nodes.runnerTarget=()=>({...target,runner:{id:runnerId,helperRetirement:true}} as GatewayTarget)
+  const request={requestId:randomUUID(),goalId:goal.id,name:'临时调研',title:'调研资料',brief:'只核验当前任务的资料'}
+  const created=await call('create_helper',request)
+  expect(await call('create_helper',request)).toEqual(created)
+  expect(created.helper).toMatchObject({execution:'computer',canManageTeam:false,temporaryGoalId:goal.id,helperActivation:1,helperRunnerId:runnerId,createdByAgentId:manager.id})
+  expect(store.list<WorkspaceConversation>(owner,'conversation').some(c=>c.kind==='direct'&&c.memberIds.includes(created.helper.id))).toBe(false)
+  expect(store.taskMemberIds(owner,team,goal.id)).toContain(created.helper.id)
+  expect(store.taskMemberIds(owner,team,randomUUID())).not.toContain(created.helper.id)
+  expect(()=>store.updateAgent(owner,created.helper.id,{canManageTeam:true})).toThrow()
+  expect(()=>store.createGroup(owner,{name:'错误团队',memberIds:[manager.id,created.helper.id],administratorId:manager.id})).toThrow()
+  expect(()=>runtime.send(owner,team.id,{requestId:randomUUID(),taskId:store.createTask(owner,team.id,{title:'另一个任务'}).id,content:'不应跨任务',mentionIds:[created.helper.id]})).toThrow()
+})
+it('retires task helpers after cancellation and retries cleanup without reviving the helper',async()=>{
+  const {team,task}=await buildTeam()
+  const goal=runtime.tasks.begin(owner,task,manager,'完成任务',{conversationId:work.conversationId,runId:work.runId,agentId:manager.id})
+  nodes.runnerTarget=()=>({...target,runner:{id:randomUUID(),helperRetirement:true}} as GatewayTarget)
+  const created=await call('create_helper',{requestId:randomUUID(),goalId:goal.id,name:'将退役的助手',title:'工作',brief:'完成工作'})
+  const cleanup=vi.spyOn(runtime,'retireHelper').mockResolvedValue(undefined)
+  store.put(owner,'turn',work.id,{...work,status:'complete',planned:true});const source=store.require<WorkspaceRun>(owner,'run',work.runId);source.status='complete';store.saveRun(owner,source)
+  vi.mocked(runtime.wake).mockRestore()
+  await runtime.stopTask(owner,team.id,task.id)
+  await vi.waitFor(()=>expect(store.require<any>(owner,'agent',created.helper.id).cleanupState).toBe('complete'))
+  expect(store.require<any>(owner,'agent',created.helper.id)).toMatchObject({archived:true,temporaryGoalId:goal.id})
+  expect(cleanup).toHaveBeenCalledTimes(1)
+  expect(()=>store.updateAgent(owner,created.helper.id,{archived:false})).toThrow()
+})