fix(chat): 任务暂停不再拦截主 Agent 对话 + 移除乐观回显消除消息错乱
- 后端 chat 处理器去掉 IsPaused 的 409 拒绝(保留 IsDeleting):主 Agent 编排会话独立于任务暂停,暂停中仍可继续对话;暂停只终止当前进行的那一轮。 - 前端 sessions-tab 删除 chatExtra 乐观回显:主 Agent 控制台改为纯服务端 数据渲染。根治伪造 seq 撞全局 DB id 导致的 React key 冲突/跨会话内容渗漏, 以及发送失败不回滚的幽灵气泡。发送失败改为回滚输入框内容 + toast 报错。 Claude-Session: https://claude.ai/code/session_01RVrPtpngE9T9DRJHosf6MB
This commit is contained in:
1 parent
28c1be735b
commit
0fc63ad94e
2 files changed
+20
-43
No files matched your search
+7
-7
@@ -1004,7 +1004,9 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) {
|
||||
case "pause":
|
||||
s.engine.Pause(t.ID, agent.AbortPausedByUser)
|
||||
// Main Agent turns run outside Engine's planner/worker context so they need
|
||||
// their own cancellation. New turns are rejected while the task is paused.
|
||||
// their own cancellation: pausing terminates the CURRENTLY running turn. New
|
||||
// turns are still allowed while paused — the main-agent console is independent
|
||||
// of task pause (the chat handler no longer rejects on IsPaused).
|
||||
s.cancelTaskChat(t.ID, agent.AbortChatPausedWithTask)
|
||||
case "resume":
|
||||
s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge
|
||||
@@ -3117,10 +3119,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 409, "任务正在删除,无法发送新消息")
|
||||
return
|
||||
}
|
||||
if s.engine.IsPaused(t.ID) {
|
||||
writeErr(w, 409, "任务已暂停,无法发送新消息")
|
||||
return
|
||||
}
|
||||
// 注意:任务暂停(paused)不拦截主 Agent 对话。主 Agent 编排会话独立于 planner/
|
||||
// worker 的暂停,暂停中仍可继续对话(暂停只终止其正在进行的那一轮,见 control())。
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
|
||||
@@ -3133,9 +3133,9 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
// turn busy before its first activity/file write, including rule-mode turns.
|
||||
// If deletion wins the race, the second barrier check rejects this request.
|
||||
s.chatMu.Lock()
|
||||
if s.engine.IsDeleting(t.ID) || s.engine.IsPaused(t.ID) {
|
||||
if s.engine.IsDeleting(t.ID) {
|
||||
s.chatMu.Unlock()
|
||||
writeErr(w, 409, "任务正在删除或已暂停,无法发送新消息")
|
||||
writeErr(w, 409, "任务正在删除,无法发送新消息")
|
||||
return
|
||||
}
|
||||
if s.chatBusy[t.ID] {
|
||||
|
||||
@@ -359,8 +359,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [olderIntents, setOlderIntents] = React.useState<TaskNode[]>([]);
|
||||
const [intentsHasMore, setIntentsHasMore] = React.useState(false);
|
||||
const [loadingOlderIntents, setLoadingOlderIntents] = React.useState(false);
|
||||
// Local-only chat messages overlaid onto the main-agent transcript.
|
||||
const [chatExtra, setChatExtra] = React.useState<Activity[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
@@ -613,7 +611,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
// ID) resumes from the DB, so a dropped realtime link self-heals; seq-merge dedups.
|
||||
React.useEffect(() => {
|
||||
setStore({});
|
||||
setChatExtra([]);
|
||||
setSseLive(false);
|
||||
setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in
|
||||
snapshotRef.current = 0;
|
||||
@@ -892,13 +889,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
// worker session shows only its intent's activity, led by the intent objective.
|
||||
const activity = React.useMemo(() => {
|
||||
const items = activeState?.items ?? [];
|
||||
if (isMain) {
|
||||
// chatExtra is an optimistic local echo — drop any entry the persisted (SSE)
|
||||
// copy already covers so messages don't show twice after the backend round-trips.
|
||||
const seen = new Set(items.map((a) => `${a.kind} ${a.summary}`));
|
||||
const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`));
|
||||
return [...items, ...pending].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
// The main-agent console renders purely from server data: the human turn is
|
||||
// persisted+broadcast by the backend BEFORE it returns, so it arrives over the
|
||||
// same SSE stream (worker="mainagent") as every agent step — no client-side
|
||||
// optimistic echo, hence no fabricated seq that could collide with real DB ids.
|
||||
if (isMain) return items;
|
||||
if (isPlanner || isSystem) return items;
|
||||
// Worker session: the intent leads the transcript as a right-aligned "user"-style
|
||||
// message (the task handed to this worker), followed by its execution steps.
|
||||
@@ -918,7 +913,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
isPlanner,
|
||||
isSystem,
|
||||
activeState,
|
||||
chatExtra,
|
||||
active.id,
|
||||
active.title,
|
||||
active.last_activity,
|
||||
@@ -1021,36 +1015,19 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const text = input.trim();
|
||||
const atts = attachments;
|
||||
if ((!text && atts.length === 0) || sending) return;
|
||||
const base = [...(store.main?.items ?? []), ...chatExtra];
|
||||
const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1;
|
||||
const userMsg: Activity = {
|
||||
seq: nextSeq,
|
||||
worker: "mainagent",
|
||||
ts: new Date().toISOString(),
|
||||
kind: "user",
|
||||
summary: text,
|
||||
// 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。
|
||||
detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined,
|
||||
};
|
||||
// optimistic echo of the human turn only; the agent's steps + final answer
|
||||
// stream back live via SSE (worker="mainagent"), so we don't append the reply.
|
||||
setChatExtra((prev) => [...prev, userMsg]);
|
||||
// No optimistic echo: the backend persists+broadcasts the human turn before it
|
||||
// returns, so it streams back over SSE (worker="mainagent") with its real DB
|
||||
// seq — the transcript renders it from server data like every other step. Clear
|
||||
// the composer eagerly for responsiveness; restore it if the send fails.
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
setSending(true);
|
||||
api
|
||||
.chat(text, taskId, atts.length > 0 ? atts : undefined)
|
||||
.catch(() => {
|
||||
setChatExtra((prev) => [
|
||||
...prev,
|
||||
{
|
||||
seq: nextSeq + 1,
|
||||
worker: "mainagent",
|
||||
ts: new Date().toISOString(),
|
||||
kind: "text",
|
||||
summary: "(发送失败,请稍后重试)",
|
||||
},
|
||||
]);
|
||||
.catch((e) => {
|
||||
setInput(text); // restore so the user doesn't lose their text / attachments
|
||||
setAttachments(atts);
|
||||
toast.error("发送失败:" + ((e as Error).message || "请稍后重试"));
|
||||
})
|
||||
.finally(() => setSending(false));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user