From 3643aed7564390f57906954286a443d5913b97a7 Mon Sep 17 00:00:00 2001 From: mateaix <57164338+mateaix@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:35:51 +0800 Subject: [PATCH] fix(team): gate pending-task auto-claim to the assignee; expose attach in tool schema --- .../channel/webchat/WebChatController.java | 14 ++++---- .../mate/team/service/TeamTaskService.java | 10 ++++-- .../vip/mate/team/tool/TeamTasksTool.java | 2 +- .../src/main/resources/docs/en/channels.md | 4 +-- .../src/main/resources/docs/en/models.md | 2 +- .../src/main/resources/docs/en/teams.md | 10 +++--- .../src/main/resources/docs/zh/channels.md | 4 +-- .../src/main/resources/docs/zh/models.md | 2 +- .../src/main/resources/docs/zh/teams.md | 10 +++--- .../team/service/TeamTaskServiceTest.java | 32 +++++++++++++++++++ .../components/chat/preview/previewKind.ts | 3 +- 11 files changed, 69 insertions(+), 24 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 1383ede2..58468044 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -1386,14 +1386,16 @@ public class WebChatController { } /** - * 重新生成最后一条助手回复。 + * Regenerate the last assistant reply. *

- * 语义:找到会话最后一条 {@code role=user} 消息 → stop 当前流(如有)→ 删除最后一条 - * {@code role=assistant} 消息 → 用 last user message 重新启动 agent turn。 - * 实际启动复用 {@link #chatStream},它会重新 saveMessage user(新消息 id,内容相同)。 - * 这样不重复 100 行 SSE 代码,代价是用户消息多一条(语义上等同"重发")。 + * Semantics: stop any in-flight stream, rewind the conversation to the last + * {@code role=user} message (removing the trailing assistant reply), then + * re-run the agent turn from that message. The restart reuses + * {@link #chatStream} with {@code internalSkipUserPersist} set, so the + * existing user row is used as the seed and no duplicate user message is + * inserted. *

- * 没有任何 user 消息时返回 400(无内容可重新生成)。 + * Returns an error when the conversation has no user message to regenerate from. */ @Operation(summary = "重新生成最后一条助手回复") @PostMapping(value = "/sessions/regenerate", produces = MediaType.TEXT_EVENT_STREAM_VALUE) diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index d82f35ba..5b64e7a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -168,8 +168,10 @@ public class TeamTaskService { /** * Complete a task with a result summary. A pending task is auto-claimed - * first (single-call convenience; safe because the claim is atomic). When - * the task requires approval it parks in in_review instead of completed. + * first (single-call convenience; safe because the claim is atomic), but + * only by its assignee — otherwise any team member could complete another + * member's not-yet-dispatched task. When the task requires approval it + * parks in in_review instead of completed. * * @return ids of dependent tasks released to pending by this completion */ @@ -177,6 +179,10 @@ public class TeamTaskService { public List completeTask(Long taskId, Long agentId, String result) { TeamTaskEntity task = requireTask(taskId); if (TeamTaskStatus.PENDING.equals(task.getStatus()) && agentId != null) { + if (task.getAssigneeAgentId() != null && !agentId.equals(task.getAssigneeAgentId())) { + throw new IllegalStateException("task #" + task.getTaskNumber() + + " is assigned to another agent; only the assignee can claim and complete it"); + } claimTask(taskId, agentId); task = requireTask(taskId); } diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java index 2a13cd1a..90e1fb29 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -66,7 +66,7 @@ public class TeamTasksTool { + "'retry' a failed/stale task back to pending (lead only; taskId). " + "Only usable when you belong to an agent team.") public String team_tasks( - @ToolParam(description = "One of: list, get, create, complete, progress, comment, cancel, retry") + @ToolParam(description = "One of: list, get, create, complete, progress, comment, attach, cancel, retry") String action, @ToolParam(description = "Task id (string form is fine) — required by every action except list/create", required = false) String taskId, diff --git a/mateclaw-server/src/main/resources/docs/en/channels.md b/mateclaw-server/src/main/resources/docs/en/channels.md index 9a47b115..a6d77e31 100644 --- a/mateclaw-server/src/main/resources/docs/en/channels.md +++ b/mateclaw-server/src/main/resources/docs/en/channels.md @@ -664,10 +664,10 @@ In any IM channel, a message that is entirely a `/`-prefixed command is intercep | `/clear` | Clear the current conversation's context (the conversation itself survives; the 1.8-era clear command folds into this framework) | | `/status` | Show the conversation's state — bound employee, model, whether a task is running | | `/stop` | Stop the running task — intercepted at the enqueue gate, so it preempts a long task mid-flight | -| `/model` | List available models / switch **this conversation's** model by name; the switch affects only the current conversation | +| `/model` | With no argument, list available models (current pin marked); `/model ` or `/model :` switches **this conversation's** model, effective from the next message; `/model reset` restores the default. Fuzzy names get suggestion lists | | `/help` | List all commands with descriptions | -Every command carries Chinese and English aliases, is case-insensitive, and matches the whole message exactly — a normal message that merely contains `/stop` never misfires. Command confirmations go through the channel's normal render-and-send path, so an already-posted "thinking…" placeholder bubble is properly consumed instead of spinning forever. +Every command carries Chinese and English aliases (e.g. `清空` / `新会话` / `状态`) and is case-insensitive. Matching is two-layered: **bare aliases match only as the entire message** ("help me write a report" is a normal prompt, not `/help`); **the slash form matches on the first token with arguments passed through** (which is how `/model qwen-max` carries its argument). A normal message that merely contains `/stop` mid-sentence never misfires. Command confirmations go through the channel's normal render-and-send path, so an already-posted "thinking…" placeholder bubble is properly consumed instead of spinning forever. --- diff --git a/mateclaw-server/src/main/resources/docs/en/models.md b/mateclaw-server/src/main/resources/docs/en/models.md index 6aac7305..f815614b 100644 --- a/mateclaw-server/src/main/resources/docs/en/models.md +++ b/mateclaw-server/src/main/resources/docs/en/models.md @@ -412,7 +412,7 @@ Failover decides *who to switch to*; 2.0.0 also makes *how each error recovers* - **"Server overloaded" and "my key is rate-limited" are treated differently.** A new OVERLOADED class: 503/529-style **server overload** means everyone is queuing — switching providers just burns the whole chain for nothing (and single-key users have nowhere to switch) — so the right move is **back off on the same provider**; a 429 on **your own key** is what deserves a fast rotation. These used to be conflated with opposite policies. - **When the provider says when it recovers, we believe it.** `Retry-After` / ratelimit-reset response headers used to go only to logs; they now **feed directly into backoff duration and health cooldown** — no more blind backoff against a known rate-limit window. - **Eviction is a TTL cooldown, not a death sentence.** Providers hard-evicted for auth failures or billing now get TTL-based readmission (swap in a new key or top up the account and the system heals itself, no restart required); a provider-stated recovery time overrides the default. -- **Jittered backoff prevents retry storms.** Concurrent conversations hitting the same rate-limited provider back off with decorrelated jitter — no more lockstep mass retries that keep re-triggering the limit. +- **Randomized jitter prevents retry storms.** Concurrent conversations hitting the same rate-limited provider back off with randomized jitter (±30% on the overload backoff tiers, exponential backoff plus a random component on the generic path) — no more lockstep mass retries that keep re-triggering the limit. ### Preferred provider drives the primary model (1.5.0) diff --git a/mateclaw-server/src/main/resources/docs/en/teams.md b/mateclaw-server/src/main/resources/docs/en/teams.md index c079f647..0934154b 100644 --- a/mateclaw-server/src/main/resources/docs/en/teams.md +++ b/mateclaw-server/src/main/resources/docs/en/teams.md @@ -11,7 +11,7 @@ head: > **Before: one employee with sub-tasks. Now: a team around a shared task board.** -Sub-agent delegation (`delegate_agent`) solves "one person temporarily calls a helper": synchronous, one-to-one, black-box. But real complex delivery looks like a project: **break down tasks, declare dependencies, run in parallel, gate on approvals, archive deliverables, and see who is doing what at any time**. +Sub-agent delegation (`delegateToAgent`) solves "one person temporarily calls a helper": synchronous, one-to-one, black-box. But real complex delivery looks like a project: **break down tasks, declare dependencies, run in parallel, gate on approvals, archive deliverables, and see who is doing what at any time**. Agent Teams bring that project machinery into MateClaw: you create a **team**, assign one **lead** employee and several **members**; tell the lead a goal, it breaks the goal into tasks on a **shared task board**; the dispatch engine hands tasks to members and runs them **in parallel**; settled results are announced back to the lead, which reviews, re-dispatches, and drives the whole thing to done. You watch it all from the Teams page — or drop tasks onto the board yourself. @@ -98,6 +98,8 @@ Leads aren't restricted by agent type. A **ReAct lead** creates tasks one by one - after hand-off the plan parks (`delegated`) and the lead's turn ends normally; the dispatch/announce loop takes over; - once all tasks settle, the wake-up passes a **parked-plan resume gate** that deterministically routes to the plan summary node, rebuilding context from task results and deliverables — the same "park in DB, resume in a fresh turn" shape the tool-approval flow already uses, with no checkpoint machinery. +The hand-off is **all-or-nothing**: the plan goes to the board only when every step resolves to a team member; if any step can't be assigned, the whole plan falls back to the original serial delegation pipeline, behaving exactly as before. + In short: **a lead that can plan turns its planning into team orchestration.** --- @@ -137,9 +139,9 @@ Data lives in five tables: `mate_agent_team`, `mate_agent_team_member`, `mate_te --- -## Teams vs. sub-agent delegation (`delegate_agent`) +## Teams vs. sub-agent delegation (`delegateToAgent`) -| | `delegate_agent` | Team task board | +| | `delegateToAgent` | Team task board | |---|---|---| | Shape | One-off helper call | Standing team + shared board | | Parallelism | Single async call | Member-level parallelism + dependency orchestration | @@ -147,7 +149,7 @@ Data lives in five tables: `mate_agent_team`, `mate_agent_team_member`, `mate_te | Interrupt/recover | Tied to parent turn | Leases, cancel-interrupt, retry | | Fits | Outsourcing one sub-problem | Multi-role, multi-step project delivery | -They coexist: a team member can still call `delegate_agent` inside its own task. +They coexist: a team member can still call `delegateToAgent` inside its own task. --- diff --git a/mateclaw-server/src/main/resources/docs/zh/channels.md b/mateclaw-server/src/main/resources/docs/zh/channels.md index c3a7dd04..86b6edbe 100644 --- a/mateclaw-server/src/main/resources/docs/zh/channels.md +++ b/mateclaw-server/src/main/resources/docs/zh/channels.md @@ -664,10 +664,10 @@ IM 渠道(企业微信、微信、钉钉)都支持语音输入。语音识 | `/clear` | 清空当前会话上下文(保留会话本身;1.8 时代的 clear 命令并入本框架) | | `/status` | 查看当前会话状态——绑定的员工、模型、是否有任务在跑 | | `/stop` | 停止正在执行的任务——在入队口拦截,可抢在长任务中途生效 | -| `/model` | 列出可用模型 / 按名切换**本会话**的模型,切换只影响当前会话 | +| `/model` | 不带参数列出可用模型(标出当前钉选);`/model <名称>` 或 `/model <提供商>:<名称>` 切换**本会话**模型,下一条消息生效;`/model reset` 恢复默认。名称模糊时给出候选建议 | | `/help` | 列出全部可用命令与说明 | -每条命令都带中英文别名,大小写不敏感、整串精确匹配——普通消息里包含 `/stop` 字样不会误触发。命令确认消息走渠道的正常渲染发送链路,所以已经贴出的"思考中"占位气泡会被正确消掉,不会留下一个永远转圈的气泡。 +每条命令都带中英文别名(如 `清空` / `新会话` / `状态`),大小写不敏感。匹配规则有两层:**裸别名只做整条消息精确匹配**("帮助我写周报"是正常提问,不会触发 `/help`);**斜杠形式按首词匹配、参数透传**(所以 `/model qwen-max` 能带参数)。正文里夹着 `/stop` 字样的普通消息不会误触发。命令确认消息走渠道的正常渲染发送链路,所以已经贴出的"思考中"占位气泡会被正确消掉,不会留下一个永远转圈的气泡。 --- diff --git a/mateclaw-server/src/main/resources/docs/zh/models.md b/mateclaw-server/src/main/resources/docs/zh/models.md index 808c61a7..847245fc 100644 --- a/mateclaw-server/src/main/resources/docs/zh/models.md +++ b/mateclaw-server/src/main/resources/docs/zh/models.md @@ -413,7 +413,7 @@ failover 决定"切给谁",2.0.0 把"什么错该怎么恢复"也做成了** - **"服务端过载"与"自己被限流"分开治**。新增 OVERLOADED 分类:503/529 这类**服务端过载**是全网都在排队,切 provider 只会把整条链白白烧一遍(单 key 用户更是无处可切)——正确动作是**同 provider 退避等待**;而 429 打在**自己 key** 上的限流才值得快速切换。以前这两种被混在一起、策略相反。 - **Provider 说几点恢复,就几点恢复**。响应头里的 `Retry-After` / ratelimit-reset 以前只进日志,现在**直接回馈到退避时长与健康冷却**——不再对着限流窗口盲退避浪费时间。 - **摘除不是判死,是带 TTL 的冷却**。认证失败、欠费被硬摘除的 provider 现在按 TTL 自动回收重试(例如换绑了新 key、账户充了值,系统自己恢复,不再需要人工重启);provider 明确给出恢复时刻时以其为准。 -- **抖动退避防重试风暴**。并发会话撞上同一个限流 provider 时,退避加去相关抖动——不会所有会话以同一节奏集体重试、持续触发限流。 +- **随机抖动防重试风暴**。并发会话撞上同一个限流 provider 时,退避带随机抖动(过载按档位 ±30%、通用路径指数退避外加随机分量)——不会所有会话以同一节奏集体重试、持续触发限流。 ### 偏好提供商决定主模型(1.5.0) diff --git a/mateclaw-server/src/main/resources/docs/zh/teams.md b/mateclaw-server/src/main/resources/docs/zh/teams.md index 502febd8..65a302cd 100644 --- a/mateclaw-server/src/main/resources/docs/zh/teams.md +++ b/mateclaw-server/src/main/resources/docs/zh/teams.md @@ -11,7 +11,7 @@ head: > **以前是"一个员工带子任务"。现在是"一个团队围着一块任务板"。** -子员工委派(`delegate_agent`)解决的是"一个人临时叫帮手":同步等结果、一对一、过程黑盒。但真实的复杂交付不长这样——它长得像一个项目:**拆任务、标依赖、并行推进、卡点审批、交付物归档、随时能看谁在干什么**。 +子员工委派(`delegateToAgent`)解决的是"一个人临时叫帮手":同步等结果、一对一、过程黑盒。但真实的复杂交付不长这样——它长得像一个项目:**拆任务、标依赖、并行推进、卡点审批、交付物归档、随时能看谁在干什么**。 团队协作把这套项目机制搬进 MateClaw:你建一个**团队**,指定一个 **Lead** 员工、若干**成员**员工;对 Lead 说一句目标,它把目标拆成任务落到**共享任务板**上;派发引擎把任务自动分给成员**并行执行**;成员完成后结果自动通报回 Lead,由它汇总、补派、直到整件事干完。你全程在 Teams 页旁观——或者直接往板上投任务。 @@ -98,6 +98,8 @@ Lead 不限定 Agent 类型。**ReAct 型 Lead** 用 `team_tasks` 逐条建任 - 移交后计划停靠(`delegated`),Lead 回合正常结束,等待由派发/通报闭环接管; - 全部任务落定后,通报唤醒经**停靠计划恢复门**确定性地路由到计划汇总节点,从任务结果与交付物重建上下文、产出总结——与工具审批"落库停靠、新一轮续跑"同构,不引入检查点机制。 +移交是**全有或全无**:只有当计划的每一步都能落到某个团队成员头上时才整体上板;有任何一步指不到成员,整个计划回落到原有的串行委派管线,行为与从前完全一致。 + 一句话:**会规划的 Lead,规划能力直接变成团队编排能力。** --- @@ -137,9 +139,9 @@ Lead 不限定 Agent 类型。**ReAct 型 Lead** 用 `team_tasks` 逐条建任 --- -## 与子员工委派(delegate_agent)怎么选 +## 与子员工委派(delegateToAgent)怎么选 -| | `delegate_agent` | 团队任务板 | +| | `delegateToAgent` | 团队任务板 | |---|---|---| | 形态 | 一对一临时叫帮手 | 常设团队 + 共享看板 | | 并行 | 单点异步 | 成员级并行 + 依赖编排 | @@ -147,7 +149,7 @@ Lead 不限定 Agent 类型。**ReAct 型 Lead** 用 `team_tasks` 逐条建任 | 中断/恢复 | 随父会话 | 租约、取消中断、重试 | | 适合 | 单个子问题外包 | 多角色多步骤的项目型交付 | -两者共存:团队成员在自己的任务里照样可以再 `delegate_agent` 叫帮手。 +两者共存:团队成员在自己的任务里照样可以再 `delegateToAgent` 叫帮手。 --- diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java index 9bb485cb..97caa222 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -166,6 +166,38 @@ class TeamTaskServiceTest { verify(taskMapper, never()).update(isNull(), any()); } + @Test + @DisplayName("a non-assignee cannot auto-claim and complete a pending task") + void completePendingByNonAssigneeRejected() { + TeamTaskEntity t = task(5L, TeamTaskStatus.PENDING); + t.setAssigneeAgentId(MEMBER_ID); + when(taskMapper.selectById(5L)).thenReturn(t); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> service.completeTask(5L, 3L, "hijack")); + assertTrue(e.getMessage().contains("only the assignee")); + // Neither the claim nor the completion update may run. + verify(taskMapper, never()).update(isNull(), any()); + } + + @Test + @DisplayName("the assignee auto-claims a pending task when completing it directly") + void completePendingByAssigneeAutoClaims() { + TeamTaskEntity pending = task(5L, TeamTaskStatus.PENDING); + pending.setAssigneeAgentId(MEMBER_ID); + TeamTaskEntity claimed = task(5L, TeamTaskStatus.IN_PROGRESS); + claimed.setAssigneeAgentId(MEMBER_ID); + claimed.setOwnerAgentId(MEMBER_ID); + // First read sees pending, the re-read after the atomic claim sees in_progress. + when(taskMapper.selectById(5L)).thenReturn(pending).thenReturn(claimed); + when(taskMapper.update(isNull(), any())).thenReturn(1); + when(taskMapper.selectList(any())).thenReturn(List.of()); + + assertTrue(service.completeTask(5L, MEMBER_ID, "done").isEmpty()); + // Claim update plus completion update. + verify(taskMapper, times(2)).update(isNull(), any()); + } + @Test @DisplayName("completing a terminal task fails with a state error") void completeTerminalRejected() { diff --git a/mateclaw-ui/src/components/chat/preview/previewKind.ts b/mateclaw-ui/src/components/chat/preview/previewKind.ts index 62b48adb..750d3c67 100644 --- a/mateclaw-ui/src/components/chat/preview/previewKind.ts +++ b/mateclaw-ui/src/components/chat/preview/previewKind.ts @@ -7,7 +7,8 @@ import type { ChatAttachment } from '@/types' * - 'docx' — rendered client-side with docx-preview * - 'sheet' — xlsx/csv parsed client-side with exceljs * - 'text' — markdown / code / plain text, reuses the chat markdown renderer - * - 'html' — rendered inside a fully sandboxed iframe (no scripts, no origin) + * - 'html' — rendered inside a sandboxed iframe (scripts allowed, opaque + * origin — no same-origin access) * - 'office' — needs server-side conversion to PDF (soffice); the frontend * requests `{url}/preview` and renders the result as 'pdf'. * Falls back to download when the server has no converter (501).