Commit 273cc67f9f80ff5ceb6fe9de10a7dc7a0f34f4c2
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
Showing
5 changed files
with
120 additions
and
17 deletions
server/routes/agent-chat.js
| ... | ... | @@ -2565,7 +2565,7 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { |
| 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('/:agentId/chat', async (req, res) => { |
| 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('agent-chat · 打断-排队(40 号文档)', () => { |
| 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('agent-chat · 打断-排队(40 号文档)', () => { |
| 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('agent-chat · 打断-排队(40 号文档)', () => { |
| 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('agentChatEvents · 串台加固', () => { |
| 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(); | ... | ... |