From 292d6ca6dfe63dcee4e7e59bfab38a0d95b800e3 Mon Sep 17 00:00:00 2001 From: Apageoflove Date: Fri, 13 Nov 2026 02:53:54 -0500 Subject: [PATCH] =?UTF-8?q?fix(minimax-bot):=20=E9=87=8D=E5=90=AF=E5=90=8E?= =?UTF-8?q?=E5=B7=B2=E6=94=B6=E4=B8=8B=E7=9A=84=E8=AF=B7=E6=B1=82=E8=BF=98?= =?UTF-8?q?=E8=83=BD=E5=86=8D=E5=8F=91=E5=87=BA=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主在没有页面可见时可以停掉插件的 Node 进程,不通知也不回调。一条已经收下、 还没产出回复的请求,重启后被 store 映射回 pending,而 pending 的含义是内存里 那个队列会负责发它——队列已经跟进程一起没了。续跑只认 held,页面按钮也只对 held 画,手动调 resume-pending 回 busy,这条请求就再也发不出去。 resumeHeld 放宽到也收这种 pending,判据用重试路径本来就在用的那条:没有任何 回复引用它,也没有落盘的助手占位,说明它从没到过宿主。队列里还活着的请求被 this.has() 排除,不会重复派发。页面在同样条件下画出本来就有的 Continue with follow-ups,并用 dispatchRevision 区分刚发出的新消息,避免它给自己画按钮。 不自动重放:进程停之前那一回合可能已经跑过工具,再发一次由用户决定。 store 的状态映射未改动,客户端按 clientMessageId 重试的路径保持原样。 两条新测试在未改的上游上红、在本切片上绿。全量 413 条通过,基线 411 条。 --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 6 +++ .../minimax-bot/miniapp/client/app.js | 9 ++++- .../minimax-bot/miniapp/node/mcp.mjs | 2 +- .../minimax-bot/miniapp/node/user-inbox.mjs | 10 ++++- plugins/minimax-ai/minimax-bot/package.json | 2 +- .../tests/client-reliability.test.mjs | 16 ++++++++ .../tests/engine-reliability.test.mjs | 38 +++++++++++++++++++ 8 files changed, 80 insertions(+), 5 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index bfc05943..923b4a3c 100644 --- a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-bot", "displayName": "MiniMax Bot", - "version": "0.7.26", + "version": "0.7.27", "description": "Turn MiniMax Code into an always-on team of Bots. A Chief Bot helps you build the team; in group chats the admin answers and @mentions hand work to other members. Every Bot has its own octopus avatar and memory, with scheduled tasks and usage stats.", "author": "MiniMax Code", "icon": "icon.png", diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 17ce0f5b..211223f8 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -95,6 +95,12 @@ The hand-over between two Bots of a group carried the tail of the previous reply - The approval claim in **Agent turns** mixed the plugin's own behaviour with the Host's. The plugin never approves a call by itself — that part was true and stays true — but whether approval is asked for at all is decided by the Host's permission mode, and a Host that never asks for approval never shows the card. The old sentence stated the first half as if it also settled the second, so it read as a promise about an outcome the plugin does not control. It now names the Host condition, and keeps the plugin's own side: when approval is needed, the plugin never approves it by itself. +## 0.7.27 A request the plugin already accepted is still sendable after a restart + +- MiniMax Code may stop the plugin's Node process while no page is visible, and it does so without a callback. A request that was already accepted and had produced no reply used to come back as waiting for a queue that no longer existed: it stayed in the chat with no answer, the page offered no way to send it again, and the resume route refused it. The request was unreachable for good. +- The page now offers the same **Continue with follow-ups** button for it, and the resume route accepts it, so the request can be sent again. Nothing is replayed on its own: the turn may already have run tools before the process stopped, so sending it again stays the user's decision. +- A request whose reply had already started is unchanged, and a request this page has just sent never offers to resume itself. + ## 0.7.10 Optional group notes - A group can keep a note of what it said. After every round the group records the closing lines of the replies, and every Bot of the group gets that record in its standing brief. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js index 35518904..9f033724 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/app.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/app.js @@ -792,7 +792,14 @@ function userDelivery(message) { const status = message.dispatchState; if (!status || status === 'processing' || status === 'delivered') return null; const key = `${message.chatId}:${message.id}`; - const button = status === 'held' ? h('button', { + // A restart leaves an accepted request pending with no queue left to send it and nothing running. + // Offer the same resume on the proof the server uses: nothing was ever written back for it. + // dispatchRevision is only written when a delivery state changes, so a request this page just + // sent has none yet and never offers to resume itself. + const stranded = status === 'pending' && message.dispatchRevision > 0 && !state.busy.has(message.chatId) + && !(state.messages.get(message.chatId) ?? []).some( + (item) => item.sourceMessageId === message.id || item.sourceMessageIds?.includes(message.id)); + const button = status === 'held' || stranded ? h('button', { type: 'button', class: 'btn sm', disabled: deliveryActions.has(key), onclick: async (event) => { if (deliveryActions.has(key)) return; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index 884b4cc1..6c0ed61c 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -662,7 +662,7 @@ export function createMcpHandler({ team, engine }) { result = { protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], capabilities: { tools: { listChanged: false } }, - serverInfo: { name: 'minimax-bot', version: '0.7.26' }, + serverInfo: { name: 'minimax-bot', version: '0.7.27' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs index 61706491..f25f44be 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/user-inbox.mjs @@ -149,7 +149,15 @@ export class UserInbox { async resumeHeld(chatId) { // Only resend supplements that never started; an uncertain original request is never replayed. const history = await this.engine.store.getMessages(chatId); - const held = history.filter((message) => message.role === 'user' && message.dispatchState === 'held' && !this.has(message.id)); + const held = history.filter((message) => { + if (message.role !== 'user' || this.has(message.id)) return false; + if (message.dispatchState === 'held') return true; + // A restart leaves an accepted request `pending` with the queue that owned it gone. It is + // recoverable on the same proof the retry path uses: no reply and no durable assistant + // placeholder means the request never reached the Host. + return message.dispatchState === 'pending' + && !history.some((item) => item.sourceMessageId === message.id || item.sourceMessageIds?.includes(message.id)); + }); if (!held.length || this.busy(chatId) || [...this.engine.tickets].some((ticket) => ticket.chatId === chatId)) { throw new this.ErrorType('busy', t('err.messageAlreadyProcessing')); } diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index d81662b6..d53dd4e1 100644 --- a/plugins/minimax-ai/minimax-bot/package.json +++ b/plugins/minimax-ai/minimax-bot/package.json @@ -1,6 +1,6 @@ { "name": "minimax-bot", - "version": "0.7.26", + "version": "0.7.27", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs index 4063caa4..f909b383 100644 --- a/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/client-reliability.test.mjs @@ -570,6 +570,22 @@ test('held supplements require an explicit continuation and a failed action leav assert.equal(calls.filter((call) => call.path.endsWith('/resume-pending')).length, 1); }); +test('a request stranded by a restart offers to send it again, and one this page just sent does not', async () => { + // dispatchRevision is written when a delivery state changes, so a recovered request has one and + // a request the composer just created does not. + const stranded = message('msg_stranded', 'Ship the hotfix', { role: 'user', dispatchState: 'pending', dispatchRevision: 1 }); + await reset([stranded]); + const button = document.querySelector('.message-delivery button'); + assert.equal(button.textContent, 'Continue with follow-ups'); + route = ({ path }) => path.endsWith('/resume-pending') ? response({ ok: true }) : undefined; + button.click(); await settle(); + assert.equal(calls.filter((call) => call.path.endsWith('/resume-pending')).length, 1); + + const fresh = message('msg_fresh', 'Just typed', { role: 'user', dispatchState: 'pending' }); + await reset([fresh]); + assert.equal(document.querySelector('.message-delivery button'), null); +}); + test('a delayed send receipt cannot restore an obsolete pending state after the live processing event', async () => { await reset(); const user = message('msg_receipt', 'Request', { role: 'user', dispatchState: 'pending', dispatchRevision: 0 }); diff --git a/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs b/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs index 4de0eb3d..9f2e312f 100644 --- a/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/engine-reliability.test.mjs @@ -648,6 +648,44 @@ test('an interrupted associated assistant is reported as uncertain, and legacy m await restored.writeChain; }); +test('a request whose queue died with the process is still sendable from the page', async (t) => { + const { dir, store, memory, engine } = await fixture(t); + const bot = store.state.bots[0]; + const chatId = `bot:${bot.id}`; + // The process died after the inbox marked the batch processing and before any reply existed. + const user = await store.appendMessage(chatId, { + role: 'user', text: 'Ship the hotfix', clientMessageId: 'orphan-1', + clientMessageHash: engine.userMessageFingerprint('Ship the hotfix', []), dispatchState: 'processing', + }); + await store.writeChain; + + const restored = new Store(dir); + await restored.init(); + let runs = 0; + const reopened = new Engine({ store: restored, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: [], agent: { + async run(input, options) { runs += 1; return { ...started(input, options, 'resumed'), status: 'finished', text: 'Shipped' }; }, + } }); + reopened.startScheduler(); + reopened.tasks.start(); + + // The load-time mapping keeps `pending`, which the client retry path needs. The queue that owned + // it is gone, so the resume route has to recognise it too or the request is stranded for good. + const afterRestart = (await restored.getMessages(chatId)).find((message) => message.id === user.id); + assert.equal(afterRestart.dispatchState, 'pending'); + await until(() => idle(reopened)); + // Nothing replays it on its own: the turn may already have run tools before the process died. + assert.equal(runs, 0); + + await reopened.inbox.resume(chatId); + await until(() => idle(reopened)); + assert.equal(runs, 1); + const messages = await restored.getMessages(chatId); + assert.equal(messages.find((message) => message.id === user.id).dispatchState, 'delivered'); + assert.equal(messages.filter((message) => message.sourceMessageId === user.id).length, 1); + reopened.dispose(); + await restored.writeChain; +}); + test('repeated pending submissions do not duplicate queued turns or revive a deliberately cancelled queue', async (t) => { const { store, engine } = await fixture(t); const bot = store.state.bots[0];