Commit 273cc67f9f80ff5ceb6fe9de10a7dc7a0f34f4c2

Authored by yibo
1 parent e9a21412

feat(agent-chat): 排队等待期间发 SSE queued/queue_timeout 信号,前端精确显示排队提示

之前排队等待(上条改动)期间前端只有通用的"思考中",最长可能等到 60s 都看不出
是在排队还是在正常生成,容易让人以为卡死了。

- agent-session-lock.js 新增 isLockBusy(agentId):判断此刻是不是真被占着(僵尸锁
  不算),供路由层在真正排队前 peek 一眼。
- agent-chat.js 的 /:agentId/chat:锁被占用时提前建立 SSE 连接、先发一个 queued
  事件(activeConvId + waitBudgetMs),前端能精确显示"排队等待中";没被占用的
  常见路径完全跳过这段,不引入多余的建连时机差异。头一旦按 200 SSE 发出去就不能
  再改状态码,排队超时也改用 queue_timeout 事件收尾,不再是独立的 429 JSON 响应
  (没被占用、一开始就抢到锁的路径不受影响,仍是普通 SSE)。
- agentChatEvents.js 的 handleEvent 认识这两个新事件类型:queued 把 thinkingText
  换成带等待预算的提示;queue_timeout 跟 error 分支一样落一条可见的助手消息,
  不会像原来的静默 429 那样挂在界面上没反应。
- 相应更新 agent-chat-interrupt.test.js(断言 SSE 里真的有 queued/queue_timeout
  事件,超时分支状态码从 429 改为 200)+ agentChatEvents.test.js 新增两条用例。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwGcvMfUcgCcweN5vjhTkn
server/routes/agent-chat.js
... ... @@ -2565,7 +2565,7 @@ router.get(&#39;/:agentId/uploads/:convId/:filename&#39;, (req, res) =&gt; {
2565 2565 // 同一 convId 的多轮对话不受限制(ACP 进程复用)
2566 2566 // 32 号文档方案 B:这把锁挪到了共享模块 agent-session-lock.js,company.js 的招聘装配
2567 2567 // 重试逻辑也要用同一把——两条路径各自维护一份的话,各自都以为自己没有并发。
2568   -const { agentChatLocks, waitForLockRelease, acquireLockWithWait } = require('../src/core/agent-session-lock');
  2568 +const { agentChatLocks, waitForLockRelease, acquireLockWithWait, isLockBusy } = require('../src/core/agent-session-lock');
2569 2569  
2570 2570 // 排队等待的预算:master/ceo 这类 agent 大部分并发碰撞是 TaskCallback 心跳唤醒跟人类
2571 2571 // 消息撞在一起,占用窗口通常几秒到几十秒——等一等就能自然衔接上,不需要立刻 429。
... ... @@ -2601,24 +2601,45 @@ router.post(&#39;/:agentId/chat&#39;, async (req, res) =&gt; {
2601 2601 // 那几条路径是 fire-and-forget,429 就等于这次唤醒直接消失、只能靠别处巡检兜底
2602 2602 // (见 docs 里 selfChat 429 漏单的教训)。改成排队等待:锁被占用先原地等一段预算,
2603 2603 // 大部分碰撞是几秒到几十秒的短占用,等一等就能自然衔接上;等满预算仍抢不到才真正 429。
  2604 + //
  2605 + // 排队期间前端只看得到通用的"思考中"提示,跟正常生成没有区别,等上几十秒容易让人
  2606 + // 以为卡死了。这里被占用时提前把 SSE 连接建起来发一个 queued 事件(带 activeConvId +
  2607 + // 等待预算),前端能精确显示"排队等待中";没被占用的常见路径完全跳过这段,不引入
  2608 + // 多余的建连时机差异。headers 一旦按 200 SSE 发出去就不能再改状态码,所以这条路径下
  2609 + // 排队超时也只能用专门的 queue_timeout 事件通知前端,不能再退回 429。
  2610 + const busyLock = isLockBusy(agentId);
  2611 + let sendSSE = null;
  2612 + if (busyLock) {
  2613 + sendSSE = setupSSE(res);
  2614 + sendSSE('queued', {
  2615 + activeConvId: busyLock.convId,
  2616 + waitBudgetMs: AGENT_CHAT_QUEUE_WAIT_MS,
  2617 + });
  2618 + }
  2619 +
2604 2620 const acquireResult = await acquireLockWithWait(agentId, convId, AGENT_CHAT_QUEUE_WAIT_MS);
2605 2621 if (!acquireResult.acquired) {
2606 2622 const { lock } = acquireResult;
2607 2623 const elapsed = Date.now() - lock.startedAt;
2608 2624 console.log(`[AgentChat] 🔒 拒绝 ${agentId} 并发请求 — 排队等待 ${(AGENT_CHAT_QUEUE_WAIT_MS / 1000).toFixed(0)}s 后仍被占用 (会话 ${lock.convId},已占 ${(elapsed / 1000).toFixed(0)}s)`);
2609   - return res.status(429).json({
  2625 + const payload = {
2610 2626 error: `Agent ${agentId} 正在执行另一个会话 (${lock.convId}),请稍后重试`,
2611 2627 activeConvId: lock.convId,
2612 2628 elapsedMs: elapsed,
2613 2629 sameConversation: lock.convId === convId,
2614   - });
  2630 + };
  2631 + if (sendSSE) {
  2632 + sendSSE('queue_timeout', payload);
  2633 + return res.end();
  2634 + }
  2635 + return res.status(429).json(payload);
2615 2636 }
2616 2637 if (acquireResult.staleLock) {
2617 2638 console.warn(`[AgentChat] ⚠️ 强制释放超时锁: ${agentId} / ${acquireResult.staleLock.convId}`);
2618 2639 }
2619 2640  
2620 2641 const msgId = `msg-${Date.now()}-${randomUUID().substring(0, 8)}`;
2621   - const sendSSE = setupSSE(res);
  2642 + if (!sendSSE) sendSSE = setupSSE(res);
2622 2643 const identity = loadIdentity(agentDir);
2623 2644  
2624 2645 const kind = resolveKind(req.body);
... ...
server/src/core/agent-session-lock.js
... ... @@ -32,6 +32,18 @@ function waitForLockRelease(agentId, budgetMs = 3000, pollMs = 100) {
32 32 }
33 33  
34 34 /**
  35 + * 判断某个 agent 此刻是不是真被占着(僵尸锁——占用超过 AGENT_LOCK_TIMEOUT——不算占用)。
  36 + * 占用中返回锁本身(供调用方取 convId/startedAt 用于提示),否则返回 null。
  37 + * 供路由层在真正排队前先 peek 一眼,决定要不要提前给前端发"排队中"信号。
  38 + */
  39 +function isLockBusy(agentId) {
  40 + const existing = agentChatLocks.get(agentId);
  41 + if (!existing) return null;
  42 + if (Date.now() - existing.startedAt >= AGENT_LOCK_TIMEOUT) return null;
  43 + return existing;
  44 +}
  45 +
  46 +/**
35 47 * 排队等待版加锁:锁被占用时不立刻让调用方判定失败,而是按 pollMs 轮询,
36 48 * 在 budgetMs 窗口内锁一空出来就同步 check-and-set(中间没有 await,
37 49 * 多个等待者不会同时都以为自己抢到),谁先摸到谁进;budget 耗尽仍抢不到才
... ... @@ -43,16 +55,16 @@ function acquireLockWithWait(agentId, convId, budgetMs, pollMs = 200) {
43 55 const deadline = Date.now() + budgetMs;
44 56 const tryAcquire = () => {
45 57 const existing = agentChatLocks.get(agentId);
46   - const stale = !!existing && Date.now() - existing.startedAt >= AGENT_LOCK_TIMEOUT;
47   - if (!existing || stale) {
  58 + const busy = isLockBusy(agentId);
  59 + if (!busy) {
48 60 agentChatLocks.set(agentId, { convId, startedAt: Date.now() });
49   - return resolve({ acquired: true, staleLock: stale ? existing : null });
  61 + return resolve({ acquired: true, staleLock: existing && !busy ? existing : null });
50 62 }
51   - if (Date.now() >= deadline) return resolve({ acquired: false, lock: existing });
  63 + if (Date.now() >= deadline) return resolve({ acquired: false, lock: busy });
52 64 setTimeout(tryAcquire, pollMs);
53 65 };
54 66 tryAcquire();
55 67 });
56 68 }
57 69  
58   -module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease, acquireLockWithWait };
  70 +module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease, acquireLockWithWait, isLockBusy };
... ...
server/tests/unit/agent-chat-interrupt.test.js
... ... @@ -7,8 +7,11 @@
7 7 * 2. `stop` 端点现在要等锁真正释放(或 3 秒预算到期)才返回,`cleaned` 字段供前端判断
8 8 * 能不能安全发第二条。
9 9 * 3. 后续改造:并发碰撞不再"立刻 429",改成排队等待 `AGENT_CHAT_QUEUE_WAIT_MS` 预算——
10   - * 等到锁释放就正常执行;等满预算仍抢不到才 429。这里用一个较短的测试专用预算
11   - * (500ms)验证两条分支:等得到 → 200,等不到 → 429。
  10 + * 等到锁释放就正常执行;等满预算仍抢不到才拒绝。这里用一个较短的测试专用预算
  11 + * (500ms)验证两条分支:等得到 → 正常执行,等不到 → 拒绝。
  12 + * 4. 排队期间前端不能只看到通用的"思考中":锁被占用时会先把 SSE 连接建起来发一个
  13 + * `queued` 事件;等满预算仍抢不到,因为响应头已经按 200 SSE 发出去、不能再改状态码,
  14 + * 改用 `queue_timeout` 事件收尾(不再是独立的 429 JSON 响应)。
12 15 */
13 16 process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
14 17 process.env.AGENT_SESSION_IDLE_MS = '60000';
... ... @@ -56,6 +59,19 @@ describe(&#39;agent-chat · 打断-排队(40 号文档)&#39;, () =&gt; {
56 59  
57 60 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58 61  
  62 + /** 把 SSE 响应体解析成 [{event, data}],供断言排队/超时事件是否真的发出来了 */
  63 + function parseSSE(text) {
  64 + const events = [];
  65 + let lastEvent = null;
  66 + for (const line of text.split('\n')) {
  67 + if (line.startsWith('event: ')) lastEvent = line.slice(7).trim();
  68 + else if (line.startsWith('data: ')) {
  69 + try { events.push({ event: lastEvent, data: JSON.parse(line.slice(6)) }); } catch { /* 忽略解析不了的行 */ }
  70 + }
  71 + }
  72 + return events;
  73 + }
  74 +
59 75 it('同一 convId 的第二个请求在锁释放预算内排队等待,等第一个跑完再执行,不会两个循环同时跑', async () => {
60 76 const conv = 'race-same-conv';
61 77 const { ClaudeCliProtocol } = require_('@mas/acp-core');
... ... @@ -80,18 +96,24 @@ describe(&#39;agent-chat · 打断-排队(40 号文档)&#39;, () =&gt; {
80 96 });
81 97  
82 98 expect(secondRes.status).toBe(200); // 排队等到了,不是瞬间 429
83   - await secondRes.text();
  99 + const secondEvents = parseSSE(await secondRes.text());
  100 + // 排队期间要先发一条 queued,前端才能显示"排队等待中"而不是通用的"思考中"
  101 + const queuedEvent = secondEvents.find(e => e.event === 'queued');
  102 + expect(queuedEvent).toBeTruthy();
  103 + expect(queuedEvent.data.activeConvId).toBe(conv);
  104 + // 等到锁之后要继续正常跑完这轮对话,不是发完 queued 就结束
  105 + expect(secondEvents.some(e => e.event === 'session')).toBe(true);
84 106 await first;
85 107  
86 108 expect(calls.length).toBe(2);
87 109 expect(calls[1].start).toBeGreaterThanOrEqual(calls[0].end); // 严格先后,没有重叠
88 110 });
89 111  
90   - it('排队等待预算耗尽仍抢不到锁时返回 429', async () => {
  112 + it('排队等待预算耗尽仍抢不到锁时以 queue_timeout 事件收尾(响应头已按 200 SSE 发出,改不了状态码)', async () => {
91 113 const conv = 'race-timeout';
92 114 const { ClaudeCliProtocol } = require_('@mas/acp-core');
93 115 const original = ClaudeCliProtocol.prototype.sendPrompt;
94   - // 延迟(900ms) > 测试预算(500ms):排队等满预算还是抢不到,只能 429。
  116 + // 延迟(900ms) > 测试预算(500ms):排队等满预算还是抢不到。
95 117 vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt').mockImplementation(function (...args) {
96 118 return sleep(900).then(() => original.apply(this, args));
97 119 });
... ... @@ -106,9 +128,13 @@ describe(&#39;agent-chat · 打断-排队(40 号文档)&#39;, () =&gt; {
106 128 body: JSON.stringify({ message: 'second', conversationId: conv }),
107 129 });
108 130  
109   - expect(second.status).toBe(429);
110   - const body = await second.json();
111   - expect(body.sameConversation).toBe(true);
  131 + expect(second.status).toBe(200); // 头已经按 SSE 发出去了,不再是独立的 429 JSON 响应
  132 + const events = parseSSE(await second.text());
  133 + expect(events.some(e => e.event === 'queued')).toBe(true);
  134 + const timeoutEvent = events.find(e => e.event === 'queue_timeout');
  135 + expect(timeoutEvent).toBeTruthy();
  136 + expect(timeoutEvent.data.sameConversation).toBe(true);
  137 + expect(timeoutEvent.data.activeConvId).toBe(conv);
112 138  
113 139 await first;
114 140 });
... ...
web/src/composables/agentChatEvents.js
... ... @@ -80,6 +80,22 @@ export function createAgentChatEventHandler({
80 80 st.thinkingText = data.message || '思考中...';
81 81 }
82 82 break;
  83 + // 429 排队等待改造:锁被占用时后端会先发这条,让"思考中"从通用文案变成
  84 + // 能看出真实原因的排队提示,而不是干等到超时/开始输出都看不出区别。
  85 + case 'queued': {
  86 + const waitSec = Math.round((data.waitBudgetMs || 0) / 1000);
  87 + st.thinkingText = `⏳ 排队等待中 — Agent 正在处理另一个会话,最多等待 ${waitSec} 秒`;
  88 + if (isCurrent) scrollToBottom();
  89 + break;
  90 + }
  91 + // 排队等到预算耗尽:这条路径的响应头已经按 200 SSE 发出去了,后端没法再改状态码,
  92 + // 只能用这个专门的事件类型收尾——跟 'error' 分支一样直接落一条可见的助手消息。
  93 + case 'queue_timeout':
  94 + st.messages.push({ role: 'assistant', content: `⏳ ${data.error || 'Agent 排队等待超时,请稍后重试'}`, timestamp: new Date().toISOString() });
  95 + st.thinkingText = '';
  96 + st.thinkingContent = '';
  97 + if (isCurrent) scrollToBottom();
  98 + break;
83 99 case 'agent_output':
84 100 st.streaming = true;
85 101 pushChunk(st, data.message || '');
... ...
web/src/composables/agentChatEvents.test.js
... ... @@ -87,6 +87,34 @@ describe(&#39;agentChatEvents · 串台加固&#39;, () =&gt; {
87 87 });
88 88 });
89 89  
  90 +describe('agentChatEvents · 排队等待提示(429 排队等待改造)', () => {
  91 + const handleEvent = createAgentChatEventHandler({
  92 + getCurrentAgentId: () => 'agent-A',
  93 + onNewConversation: vi.fn(),
  94 + scrollToBottom: vi.fn(),
  95 + scrollAcpLog: vi.fn(),
  96 + scrollSubLogs: vi.fn(),
  97 + });
  98 +
  99 + it('queued 事件把思考文案换成带等待预算的排队提示,而不是通用的"思考中"', () => {
  100 + const st = makeState();
  101 + handleEvent(st, 'agent-A', 'queued', { activeConvId: 'conv-busy', waitBudgetMs: 60000 });
  102 + expect(st.thinkingText).toContain('排队等待中');
  103 + expect(st.thinkingText).toContain('60');
  104 + });
  105 +
  106 + it('queue_timeout 事件落一条可见的助手消息,而不是像原来 429 那样静默无回复', () => {
  107 + const st = makeState();
  108 + handleEvent(st, 'agent-A', 'queued', { activeConvId: 'conv-busy', waitBudgetMs: 500 });
  109 + handleEvent(st, 'agent-A', 'queue_timeout', { error: 'Agent 正在执行另一个会话 (conv-busy),请稍后重试' });
  110 +
  111 + expect(st.messages).toHaveLength(1);
  112 + expect(st.messages[0].role).toBe('assistant');
  113 + expect(st.messages[0].content).toContain('conv-busy');
  114 + expect(st.thinkingText).toBe(''); // 收尾清空,不留一个卡住的"排队等待中"
  115 + });
  116 +});
  117 +
90 118 describe('agentChatEvents · 打字机渲染缓冲', () => {
91 119 it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => {
92 120 vi.useFakeTimers();
... ...