Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"name": "minimax-bot",
"displayName": "MiniMax Bot",
"version": "0.7.23",
"version": "0.7.24",
"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",
Expand Down
5 changes: 5 additions & 0 deletions plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ The usage ledger counts the time the model really ran. A retried turn waits betw

The hand-over between two Bots of a group carried the tail of the previous reply: the last thing one Bot said, not what the round had concluded — and a discussion concludes over several replies, not in the closing line of one of them. The hand-over now carries the conclusions of the round so far, one entry per hop and each with the Bot that said it, the way a seminar hands its conclusion to the next speaker instead of the last sentence that was said. A round with a single hop hands over exactly what it handed over before, byte for byte. The note setting does not change any of this: the hand-over reads the round itself, and a round is built whether or not the group keeps a note. The note stays what it always was, a record of the earlier rounds that lives in the standing brief of every Bot.

## 0.7.24 A file that cannot be read, and a ledger that cannot be written

- A `state.json` or a chat file that cannot be parsed no longer keeps the Mini App from starting. The unreadable file is kept as `*.corrupt-<time>` instead of being thrown away, and the store starts from the defaults: an unreadable chat starts empty, the other chats still load. The file was written with a rename, but nothing was flushed before it, so a power loss could leave an empty file behind; the bytes are flushed before the rename now, and the directory after it where the platform allows it.
- A failed ledger write no longer fails the turn that already ran. The reply keeps its outcome, a group round goes on to the next member, and a finished background job still saves its status and delivers its report. The miss goes to the log.

## 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.
Expand Down
73 changes: 42 additions & 31 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -881,22 +881,28 @@ export class Engine {
}
} finally {
// The model ran: the ledger must say so even when a later step of this turn fails.
await this.store.recordUsage({
ts: Date.now(),
turnId: message.id,
botId: bot.id,
botName: bot.name,
chatId,
kind: input.kind,
status,
durationMs: message.durationMs,
chars: finalText.length,
tools,
// One turn, but the model may have been called more than once to finish it.
attempts: modelCalls,
// The wait between two attempts is recorded on its own: it is not the model's time.
sleptMs,
});
// The ledger is metering, not the turn's outcome: a failed write (a full disk) must
// not take the turn that already ran down with it. The miss goes to the log.
try {
await this.store.recordUsage({
ts: Date.now(),
turnId: message.id,
botId: bot.id,
botName: bot.name,
chatId,
kind: input.kind,
status,
durationMs: message.durationMs,
chars: finalText.length,
tools,
// One turn, but the model may have been called more than once to finish it.
attempts: modelCalls,
// The wait between two attempts is recorded on its own: it is not the model's time.
sleptMs,
});
} catch (error) {
this.logger.warn('minimax_bot.usage_write_failed', { chatId, code: error?.code ?? 'unknown' });
}
this.broadcast({ type: 'usage.changed' });
}
this.broadcast({ type: 'message.upsert', chatId, message });
Expand Down Expand Up @@ -1915,21 +1921,26 @@ export class Engine {
if (report) helper.report = report;
if (turnStartedAt) {
// A background job is an Agent turn like any other: meter it here, or the usage panel
// counts fewer turns than the model actually ran.
await this.store.recordUsage({
ts: Date.now(),
turnId: helper.id,
botId: helper.botId,
botName: turnBotName,
chatId: helper.chatId,
kind: 'helper-run',
status: helperTurnStatus(helper.status),
durationMs: turnMs,
chars: turnText.length,
tools: turnTools,
// A background job runs the model once; it has no retry loop of its own.
attempts: 1,
});
// counts fewer turns than the model actually ran. A failed write must not keep the
// finished job from saving its status and delivering its report.
try {
await this.store.recordUsage({
ts: Date.now(),
turnId: helper.id,
botId: helper.botId,
botName: turnBotName,
chatId: helper.chatId,
kind: 'helper-run',
status: helperTurnStatus(helper.status),
durationMs: turnMs,
chars: turnText.length,
tools: turnTools,
// A background job runs the model once; it has no retry loop of its own.
attempts: 1,
});
} catch (error) {
this.logger.warn('minimax_bot.usage_write_failed', { chatId: helper.chatId, code: error?.code ?? 'unknown' });
}
this.broadcast({ type: 'usage.changed' });
}
if (this.disposed) return;
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.23' },
serverInfo: { name: 'minimax-bot', version: '0.7.24' },
instructions: t('mcp.instructions'),
};
break;
Expand Down
3 changes: 2 additions & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ function chatReferences(messages, src) {
*/
export async function start(context) {
const clientDir = join(context.pluginRoot, 'miniapp/client');
const store = new Store(context.dataDir);
// The store writes the ledger; when that write fails it says so through the host's log.
const store = new Store(context.dataDir, context.logger);
const memory = new MemoryStore(context.dataDir);
await store.init();
await memory.init();
Expand Down
66 changes: 56 additions & 10 deletions plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
import { randomUUID } from 'node:crypto';
import { appendFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { appendFile, copyFile, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { userReaction } from '../client/emoji-data.js';

import { resolveLocale, setLocale, t } from './i18n.mjs';
Expand Down Expand Up @@ -120,23 +120,50 @@ export function newId(prefix) {
async function writeJsonAtomic(path, value) {
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
try {
await writeFile(tmp, JSON.stringify(value, null, 2), 'utf8');
// Flush the bytes before the rename: a power loss after an unflushed rename is what
// leaves a 0-byte file behind.
const handle = await open(tmp, 'w');
try {
await handle.writeFile(JSON.stringify(value, null, 2), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(tmp, path);
// Best effort: some platforms (Windows) cannot sync a directory handle.
await open(dirname(path), 'r').then((dir) => dir.sync().finally(() => dir.close())).catch(() => {});
} catch (error) {
await rm(tmp, { force: true }).catch(() => {});
throw error;
}
}

async function readJson(path, fallback) {
async function readJson(path, fallback, logger) {
let text;
try {
return JSON.parse(await readFile(path, 'utf8'));
text = await readFile(path, 'utf8');
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return fallback;
}
throw error;
}
try {
return JSON.parse(text);
} catch {
// Keep the unreadable bytes as evidence instead of deleting them; start from defaults.
const kept = `${path}.corrupt-${Date.now()}`;
try {
await rename(path, kept);
} catch {
// The rename can fail (the file may be held open). A copy keeps the bytes even when the
// name cannot change; the next save may write over the original, but the copy stays.
await copyFile(path, kept).catch(() => {});
}
// A team that came back empty, or a chat that reads empty, is otherwise unexplainable.
logger?.warn('minimax_bot.corrupt_file_quarantined', { file: basename(path) });
return fallback;
}
}

function defaultState() {
Expand Down Expand Up @@ -213,9 +240,11 @@ function knownText(message) {
}

export class Store {
/** @param {string} dataDir */
constructor(dataDir) {
/** @param {string} dataDir @param {{ warn: (...args: any[]) => void }} [logger] */
constructor(dataDir, logger) {
this.dir = dataDir;
/** The ledger must be able to say it lost a row; without a host logger it stays quiet. */
this.logger = logger ?? { warn() {} };
this.statePath = join(dataDir, 'state.json');
this.messagesDir = join(dataDir, 'messages');
this.usagePath = join(dataDir, 'usage.jsonl');
Expand All @@ -236,10 +265,20 @@ export class Store {

async init() {
await mkdir(this.messagesDir, { recursive: true });
this.state = { ...defaultState(), ...(await readJson(this.statePath, defaultState())) };
this.state = { ...defaultState(), ...(await readJson(this.statePath, defaultState(), this.logger)) };
this.state.settings = { ...defaultState().settings, ...(this.state.settings ?? {}) };
setLocale(resolveLocale(this.state));
let migrated = false;
// The fields init walks must have the shape it walks: a wrong shape starts from defaults
// rather than throwing half way through startup.
for (const key of ['bots', 'groups', 'schedules']) {
if (!Array.isArray(this.state[key])) { this.state[key] = []; migrated = true; }
}
const sessions = this.state.sessions;
if (!sessions || typeof sessions !== 'object' || Array.isArray(sessions)) {
this.state.sessions = {};
migrated = true;
}
// A helper's Agent run does not survive the Mini App process; one left running was cut off.
this.state.helpers = Array.isArray(this.state.helpers) ? this.state.helpers : [];
for (const helper of this.state.helpers) {
Expand Down Expand Up @@ -410,7 +449,7 @@ export class Store {
}

async loadMessages(chatId) {
let list = await readJson(this.messagePath(chatId), []);
let list = await readJson(this.messagePath(chatId), [], this.logger);
// deleteChat() may have installed an empty list while the read was in flight; it wins.
const installed = this.messageCache.get(chatId);
if (installed) return installed;
Expand Down Expand Up @@ -620,7 +659,14 @@ export class Store {
// ---------- usage ----------
recordUsage(entry) {
return this.enqueue(async () => {
await appendFile(this.usagePath, `${JSON.stringify(entry)}\n`, 'utf8');
// The ledger is metering: a failed append (a full disk) must not fail the caller's turn,
// but the miss goes to the log. The id is added only on success, so a retry can still write.
try {
await appendFile(this.usagePath, `${JSON.stringify(entry)}\n`, 'utf8');
} catch (error) {
this.logger.warn('minimax_bot.usage_write_failed', { code: error?.code ?? 'unknown' });
return;
}
if (entry.turnId) this.usageIds.add(entry.turnId);
});
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minimax-bot",
"version": "0.7.23",
"version": "0.7.24",
"private": true,
"type": "module",
"mcode": {
Expand Down
127 changes: 127 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/ledger-write-failure.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../miniapp/node/store.mjs';
import { MemoryStore } from '../miniapp/node/memory.mjs';
import { Engine } from '../miniapp/node/engine.mjs';
import { setLocale } from '../miniapp/node/i18n.mjs';

// The ledger is metering, not the turn's outcome: a failed write (a full disk) must not fail a
// turn that already ran, stop a group round, or keep a finished background job from reporting.

async function until(predicate) {
for (let i = 0; i < 400; i += 1) {
if (await predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error('The expected state did not arrive');
}

async function fixture(t, { stub = true } = {}) {
const dir = await mkdtemp(join(tmpdir(), 'bot-ledger-failure-'));
const warnings = [];
const logger = { warn: (...args) => warnings.push(args), info() {}, error() {} };
const store = new Store(dir, logger);
const memory = new MemoryStore(dir);
await store.init();
await memory.init();
setLocale('en');
const engine = new Engine({ store, memory, logger, broadcast() {}, retryDelaysMs: [] });
t.after(async () => {
engine.dispose();
await until(() => engine.running.size === 0 && engine.helperRuns.size === 0 && engine.tickets.size === 0);
await store.writeChain;
await rm(dir, { recursive: true, force: true });
});
if (stub) {
// The whole ledger write fails, exactly like a full disk; the guard must live at the call site.
store.recordUsage = async () => { throw Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' }); };
}
return { dir, store, engine, warnings };
}

const loggedMiss = (warnings) => warnings.some(([event]) => event === 'minimax_bot.usage_write_failed');

test('a full ledger does not fail the turn that already ran', async (t) => {
const { store, engine, warnings } = await fixture(t);
const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' };
store.state.bots = [bot];
engine.agent = { async run(input, options) {
const ids = { sessionId: 's-ledger', turnId: 't-ledger' };
options.onEvent({ type: 'turn.started', ...ids });
return { ...ids, status: 'finished', text: 'All done' };
} };
const result = await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'hi', kind: 'private' });
assert.equal(result.status, 'done', 'the reply keeps its outcome');
const messages = await store.getMessages(`bot:${bot.id}`);
assert.equal(messages.find((m) => m.id === result.messageId)?.status, 'done');
assert.ok(loggedMiss(warnings), 'the miss goes to the log');
});

test('a full ledger does not stop the group round before the next member', async (t) => {
const { store, engine, warnings } = await fixture(t);
const a = { id: 'bot_a', name: 'Alice', role: 'Writer' };
const b = { id: 'bot_b', name: 'Bob', role: 'Reviewer' };
const group = { id: 'grp_round', name: 'Round', adminId: a.id, memberIds: [a.id, b.id], replyMode: 'all' };
store.state.bots = [a, b];
store.state.groups = [group];
const calls = [];
engine.agent = { async run(input, options) {
calls.push(input.text);
const ids = { sessionId: `s-${calls.length}`, turnId: `t-${calls.length}` };
options.onEvent({ type: 'turn.started', ...ids });
return { ...ids, status: 'finished', text: `Reply ${calls.length}` };
} };
await engine.driveGroup(group, engine.groupQueue(group, 'hi'));
assert.equal(calls.length, 2, 'the round continues with the next member');
assert.ok(loggedMiss(warnings), 'the miss goes to the log');
});

test('a full ledger does not keep a finished background job from saving and reporting', async (t) => {
const { dir, store, engine, warnings } = await fixture(t);
const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' };
store.state.bots = [bot];
const chatId = `bot:${bot.id}`;
const calls = [];
engine.agent = { async run(input, options) {
calls.push(input.text);
const ids = { sessionId: `s-${calls.length}`, turnId: `t-${calls.length}` };
options.onEvent({ type: 'turn.started', ...ids });
if (input.text.startsWith('<minimax-bot-helper>')) return { ...ids, status: 'finished', text: 'BACKGROUND_ANSWER' };
if (input.text.endsWith('START_BACKGROUND')) {
await engine.startHelper(bot, { task: 'Look this up' });
return { ...ids, status: 'finished', text: 'Started' };
}
return { ...ids, status: 'finished', text: 'Relay' };
} };
await engine.runBotTurn({ bot, chatId, text: 'START_BACKGROUND', kind: 'private' });
await until(() => store.state.helpers[0]?.status === 'done');
const messages = await store.getMessages(chatId);
assert.ok(messages.some((m) => m.role === 'helper'), 'the finished job delivers its report');
const onDisk = JSON.parse(await readFile(join(dir, 'state.json'), 'utf8'));
assert.equal(onDisk.helpers.find((h) => h.id === store.state.helpers[0].id)?.status, 'done', 'the status is saved, not left running');
assert.ok(loggedMiss(warnings), 'the miss goes to the log');
});

test('a real failing ledger append is logged, not silently dropped', async (t) => {
// No stub: the real Store.recordUsage must survive the failed append AND log the miss,
// which is what the README promises for a full disk in production.
const { dir, store, engine, warnings } = await fixture(t, { stub: false });
const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' };
store.state.bots = [bot];
// appendFile onto a directory fails (EISDIR) the way a full disk fails the append.
store.usagePath = dir;
engine.agent = { async run(input, options) {
const ids = { sessionId: 's-real', turnId: 't-real' };
options.onEvent({ type: 'turn.started', ...ids });
return { ...ids, status: 'finished', text: 'All done' };
} };
const result = await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'hi', kind: 'private' });
assert.equal(result.status, 'done', 'the reply keeps its outcome');
const messages = await store.getMessages(`bot:${bot.id}`);
assert.equal(messages.find((m) => m.id === result.messageId)?.status, 'done');
assert.ok(loggedMiss(warnings), 'the real store path must log the lost row');
assert.equal(store.usageIds.size, 0, 'a failed append must not claim the turn id, so a retry can still write');
});
Loading
Loading