mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
execute_code - Cancellation now reaches inside the SDK via an AsyncLocalStorage scope: timeout, MCP cancel, or a newer call rejects pending waits and blocks further action packets, including those inside walkTo(). - One execution per bot: a new call supersedes the running one and starts once it has unwound; disconnect_bot does the same. - Cancelled user code parks on bot/sdk calls instead of throwing, so a catch-all loop can't spin and starve the server. - Console capture is routed per execution, so concurrent bots no longer clobber each other's output; leftover logs go to stderr. - Stale world state warns and still runs, so a stale client can be diagnosed. - Credentials are redacted from logs, results and launch messages. SDK - bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that success can mean the action only started. - walkTo() takes options.timeout (default 120s), refuses stale state, and reports the real last position. - runScript uses the same cancellation scope for timeouts and signals. Docs trimmed of duplicated or stale execute_code text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
7.6 KiB
TypeScript
154 lines
7.6 KiB
TypeScript
import type { BotConnection } from './api/index';
|
|
import { ConsoleCapture, formatLogArgument, installExecutionConsoleCapture } from './console-capture';
|
|
import { executionContext, abortable, throwIfExecutionAborted } from '../sdk/execution-context';
|
|
import { formatWorldState } from '../sdk/formatter';
|
|
import { Spells } from '../sdk/spells';
|
|
import { redactCredentials } from '../sdk/redact';
|
|
|
|
/** One execution per character: a new call cancels the running one and starts once it has unwound. */
|
|
export class BotExecutions {
|
|
private current = new Map<string, { controller: AbortController; done: Promise<unknown> }>();
|
|
|
|
async run<T>(name: string, signal: AbortSignal | undefined, task: (controller: AbortController) => Promise<T>): Promise<T> {
|
|
const key = name.toLowerCase();
|
|
const previous = this.current.get(key);
|
|
previous?.controller.abort(new Error('Superseded by a newer call for this bot'));
|
|
const controller = new AbortController();
|
|
const onClientAbort = () => controller.abort(signal?.reason ?? new Error('Cancelled by client'));
|
|
signal?.addEventListener('abort', onClientAbort, { once: true });
|
|
if (signal?.aborted) onClientAbort();
|
|
const done = (async () => {
|
|
await previous?.done; // the old run releases the bot before the new one touches it
|
|
controller.signal.throwIfAborted();
|
|
return task(controller);
|
|
})();
|
|
const entry = { controller, done: done.catch(() => {}) };
|
|
this.current.set(key, entry);
|
|
try {
|
|
return await done;
|
|
} finally {
|
|
signal?.removeEventListener('abort', onClientAbort);
|
|
if (this.current.get(key) === entry) this.current.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const botExecutions = new BotExecutions();
|
|
type Manager = { get(name: string): BotConnection | undefined; connect(name: string): Promise<BotConnection> };
|
|
|
|
/** Records which bot/sdk calls are in flight for diagnostics; once cancelled, calls park instead of throwing. */
|
|
function tracked<T extends object>(object: T, label: string, active: Map<symbol, string>, signal: AbortSignal): T {
|
|
return new Proxy(object, {
|
|
get(target, prop) {
|
|
const value = Reflect.get(target, prop, target);
|
|
if (typeof value !== 'function') return value;
|
|
return (...args: unknown[]) => {
|
|
// A throw here would let a catch-all loop in cancelled code spin and starve the server.
|
|
if (signal.aborted) return new Promise(() => {});
|
|
const token = Symbol();
|
|
active.set(token, `${label}.${String(prop)}`);
|
|
try {
|
|
const result = value.apply(target, args);
|
|
if (result && typeof result.then === 'function') return Promise.resolve(result).finally(() => active.delete(token));
|
|
active.delete(token);
|
|
return result;
|
|
} catch (error) { active.delete(token); throw error; }
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function executeCode(
|
|
manager: Manager,
|
|
botName: string,
|
|
code: string,
|
|
timeoutMs: number,
|
|
clientSignal?: AbortSignal,
|
|
executions = botExecutions,
|
|
) {
|
|
installExecutionConsoleCapture();
|
|
try {
|
|
return await executions.run(botName, clientSignal, async controller => {
|
|
const logs = new ConsoleCapture();
|
|
const active = new Map<symbol, string>();
|
|
const record = (level: 'log' | 'warn' | 'error', args: unknown[]) => {
|
|
logs.push((level === 'log' ? '' : `[${level}] `) + args.map(formatLogArgument).join(' '));
|
|
};
|
|
const started = Date.now();
|
|
let connection: BotConnection | undefined;
|
|
let result: unknown;
|
|
let failure: unknown;
|
|
let failed = false;
|
|
let interruptedActions: string[] = [];
|
|
const rememberActions = () => { interruptedActions = [...active.values()]; };
|
|
controller.signal.addEventListener('abort', rememberActions, { once: true });
|
|
const timer = setTimeout(() => controller.abort(new Error(`Code execution timed out after ${timeoutMs / 60_000} minute(s)`)), timeoutMs);
|
|
|
|
const work = async () => {
|
|
const connecting = Symbol();
|
|
active.set(connecting, 'Connecting / waiting for fresh state');
|
|
try {
|
|
connection = manager.get(botName);
|
|
if (!connection?.connected) connection = await manager.connect(botName);
|
|
const sdk = connection.sdk;
|
|
try {
|
|
if (sdk.getStateAge() > 10_000) await sdk.waitForStateChange(15_000);
|
|
if (!sdk.getState()?.inGame) await sdk.waitForReady(15_000);
|
|
} catch (error) {
|
|
throwIfExecutionAborted();
|
|
// Still run: diagnosing a stale client is exactly when you need execute_code.
|
|
record('warn', [`No fresh in-game state (${error instanceof Error ? error.message : error}); running anyway, actions may not take effect.`]);
|
|
}
|
|
} finally { active.delete(connecting); }
|
|
throwIfExecutionAborted();
|
|
|
|
const compiled = new Bun.Transpiler({ loader: 'ts' }).transformSync(
|
|
`const __rsExec = async (bot, sdk, Spells) => {\n${code}\n};`,
|
|
);
|
|
const fn = new Function(`${compiled}\nreturn __rsExec;`)();
|
|
return fn(tracked(connection.bot, 'bot', active, controller.signal), tracked(connection.sdk, 'sdk', active, controller.signal), Spells);
|
|
};
|
|
|
|
try {
|
|
result = await executionContext.run({ signal: controller.signal, log: record }, () => abortable(work()));
|
|
} catch (error) {
|
|
failure = error;
|
|
failed = true;
|
|
if (interruptedActions.length === 0) interruptedActions = [...active.values()];
|
|
} finally {
|
|
clearTimeout(timer);
|
|
controller.signal.removeEventListener('abort', rememberActions);
|
|
// Detached continuations inherit this scope and must not dispatch after it ends.
|
|
if (!controller.signal.aborted) controller.abort(new Error('Execution finished'));
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
if (logs.length) parts.push('── Console ──', ...logs.lines());
|
|
if (failed) {
|
|
const errorText = failure instanceof Error
|
|
? formatLogArgument(failure).split('\n').slice(0, 7).join('\n')
|
|
: formatLogArgument(failure);
|
|
parts.push('', '── Error ──', errorText,
|
|
`Elapsed: ${Date.now() - started}ms`,
|
|
`Active action: ${interruptedActions.join(', ') || 'User code'}`);
|
|
} else if (result !== undefined) {
|
|
try { parts.push('', '── Result ──', redactCredentials(JSON.stringify(result, null, 2) ?? String(result))); }
|
|
catch (error) { failure = error; failed = true; parts.push('', '── Error ──', formatLogArgument(error)); }
|
|
}
|
|
const state = connection?.sdk.getState();
|
|
if (state) {
|
|
const ticks = state.gameMessages?.map(message => message.tick) ?? [];
|
|
if (ticks.length && ticks.every(tick => tick < connection!.lastShownMessageTick)) connection!.lastShownMessageTick = -1;
|
|
const sinceTick = connection!.lastShownMessageTick;
|
|
parts.push('', '── World State ──', redactCredentials(formatWorldState(state, connection!.sdk.getStateAge(), { sinceTick, brief: true })));
|
|
// Advance only from messages present in this snapshot; tolerate client cycle resets.
|
|
if (ticks.length) connection!.lastShownMessageTick = Math.max(connection!.lastShownMessageTick, ...ticks);
|
|
}
|
|
if (code.length > 2000) parts.push('', '── Tip ──', `For long runs, save a script under bots/${botName}/ and run it against the browser client already opened by MCP.`);
|
|
return { content: [{ type: 'text' as const, text: parts.join('\n').trim() || '(no output)' }], ...(failed ? { isError: true } : {}) };
|
|
});
|
|
} catch (error) {
|
|
return { content: [{ type: 'text' as const, text: `Error: ${formatLogArgument(error)}` }], isError: true };
|
|
}
|
|
}
|