From 2fd1e986b599fa36df178aef28c00b2028e2f558 Mon Sep 17 00:00:00 2001 From: "zhubowen.cc" Date: Thu, 11 Jun 2026 16:22:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(worker-budget):=20auto=20=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E6=8C=89=E5=90=8C=E6=9C=BA=20bot=20=E6=95=B0=E5=9D=87?= =?UTF-8?q?=E6=91=8A=EF=BC=8C=E9=81=BF=E5=85=8D=E5=A4=9A=20daemon=20?= =?UTF-8?q?=E5=90=84=E5=8D=A0=E6=95=B4=E6=9C=BA=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多 daemon 部署(一 bot 一进程)下,每个 daemon 都按整机 cpu/内存独立推导 maxLiveWorkers(如 56C/110G 机器 6 bot = 6×32=192),机器级上限被静默放大 N 倍,idle sweeper 形同虚设。改为机器预算 ÷ bots.json bot 数后再 clamp: - autoMaxLiveWorkers/resolveWorkerBudget 增加 daemonCount 参数(默认 1, 单 bot 行为不变) - bot-registry 新增 countConfiguredBots():mtime 缓存读共享 bots.json, CLI 一次性进程无注册表时回退 registry 大小 - idle sweeper 与 worker-budget status/set 传入实际 bot 数;status 的 auto baseline 行展示 daemons 分母 --- src/bot-registry.ts | 26 ++++++++++++++++++++++++++ src/cli.ts | 9 +++++---- src/core/idle-worker-sweeper.ts | 3 ++- src/core/worker-budget.ts | 18 +++++++++++++++--- test/worker-budget.test.ts | 23 ++++++++++++++++++++++- 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 28b6e3ce..e9e612fa 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -454,6 +454,32 @@ export function resolveBrandLabel(larkAppId: string): string | undefined { } } +// Configured-bot count, mtime-cached like the other disk fallbacks above. +let botCountCache: { mtimeMs: number; count: number } | null = null; + +/** + * How many bots the shared bots.json configures — i.e. how many daemons share + * this machine (multi-daemon deployments run one bot per process, so the + * in-memory registry only sees this daemon's own bot). Used to split the + * auto-derived worker budget across daemons. Falls back to the in-memory + * registry size (≥1) when no config file is readable, which also covers + * single-process/test setups. + */ +export function countConfiguredBots(): number { + const path = loadedConfigPath ?? botsConfigDiskPath(); + if (path) { + try { + const stat = statSync(path); + if (!botCountCache || botCountCache.mtimeMs !== stat.mtimeMs) { + const raw = JSON.parse(readFileSync(path, 'utf-8')); + botCountCache = { mtimeMs: stat.mtimeMs, count: Array.isArray(raw) ? Math.max(1, raw.length) : 1 }; + } + return botCountCache.count; + } catch { /* fall through to registry size */ } + } + return Math.max(1, bots.size); +} + /** * Load bot configurations from one of (in priority order): * 1. BOTS_CONFIG env var — path to a JSON file diff --git a/src/cli.ts b/src/cli.ts index 22c4ac8a..166735b4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3071,7 +3071,7 @@ function argValues(args: string[], ...flags: string[]): string[] { import { buildMentionedPendingResponseCard } from './im/lark/card-builder.js'; import { buildCardBodyElements, brandFooterSegment } from './im/lark/md-card.js'; import { COMPLETED_REACTION_EMOJI_TYPE, claimPendingResponseCard, isPendingResponseCardOpen, markPendingResponseCardPatchedIfCurrent, mergePendingResponseState, shouldMarkPendingAsMentionedSend, shouldPatchPendingOnExplicitSend } from './core/pending-response.js'; -import { resolveBrandLabel } from './bot-registry.js'; +import { countConfiguredBots, resolveBrandLabel } from './bot-registry.js'; import { config } from './config.js'; import { resolveQuoteTarget, validateMentionDecision, parseAttentionFlag, attentionUsageError } from './services/send-policy.js'; @@ -4911,11 +4911,12 @@ function cmdWorkerBudget(args: string[]): void { if (sub === 'status') { const cfg = readGlobalConfig(); const resources = detectWorkerResources(); - const budget = resolveWorkerBudget(cfg.worker, resources); + const daemonCount = countConfiguredBots(); + const budget = resolveWorkerBudget(cfg.worker, resources, daemonCount); console.log('Worker budget'); console.log(` maxLiveWorkers: ${budget.maxLiveWorkers} (${budget.maxLiveWorkersSource})`); console.log(` idleSuspendMs: ${budget.idleSuspendMs} (${budget.idleSuspendMsSource})`); - console.log(` auto baseline: ${budget.autoMaxLiveWorkers} from cpu=${resources.cpuCount}, memory=${formatGib(resources.memoryBytes)}`); + console.log(` auto baseline: ${budget.autoMaxLiveWorkers} from cpu=${resources.cpuCount}, memory=${formatGib(resources.memoryBytes)}, daemons=${daemonCount} (per-daemon share)`); console.log(` Config file: ${globalConfigPath()}`); console.log(''); console.log('Agent-safe edit commands:'); @@ -4945,7 +4946,7 @@ function cmdWorkerBudget(args: string[]): void { if (idleMinutes !== undefined) next.idleSuspendMs = parsePositiveInt(idleMinutes, '--idle-minutes') * 60_000; mergeGlobalConfig({ worker: next }); - const budget = resolveWorkerBudget(next); + const budget = resolveWorkerBudget(next, undefined, countConfiguredBots()); console.log('✅ Updated worker budget.'); console.log(` maxLiveWorkers: ${budget.maxLiveWorkers} (${budget.maxLiveWorkersSource})`); console.log(` idleSuspendMs: ${budget.idleSuspendMs} (${budget.idleSuspendMsSource})`); diff --git a/src/core/idle-worker-sweeper.ts b/src/core/idle-worker-sweeper.ts index f4339813..5ced96e7 100644 --- a/src/core/idle-worker-sweeper.ts +++ b/src/core/idle-worker-sweeper.ts @@ -1,4 +1,5 @@ import type { DaemonSession } from './types.js'; +import { countConfiguredBots } from '../bot-registry.js'; import { readGlobalConfig } from '../global-config.js'; import { DEFAULT_IDLE_SUSPEND_MS, resolveWorkerBudget, type ResolvedWorkerBudget } from './worker-budget.js'; import { suspendWorker } from './worker-pool.js'; @@ -25,7 +26,7 @@ export function sweepIdleWorkers( opts: IdleWorkerSweepOptions = {}, ): IdleWorkerSweepResult[] { const now = opts.now ?? Date.now(); - const budget = opts.workerBudget ?? resolveWorkerBudget(readGlobalConfig().worker); + const budget = opts.workerBudget ?? resolveWorkerBudget(readGlobalConfig().worker, undefined, countConfiguredBots()); const maxLiveWorkers = budget.maxLiveWorkers; const idleMs = budget.idleSuspendMs; const running = liveWorkers(activeSessions); diff --git a/src/core/worker-budget.ts b/src/core/worker-budget.ts index 484ef7f7..640d4ff0 100644 --- a/src/core/worker-budget.ts +++ b/src/core/worker-budget.ts @@ -49,17 +49,29 @@ export function detectWorkerResources(): WorkerResources { }; } -export function autoMaxLiveWorkers(resources: WorkerResources = detectWorkerResources()): number { +/** + * Auto-derived per-daemon live-worker budget. + * + * `daemonCount` is the number of daemons sharing this machine (= bots in the + * shared bots.json; multi-daemon deployments run one bot per process). The + * machine-level budget min(cpu×2, memGiB) is split evenly across daemons — + * without this, N daemons each claim the whole box and the effective + * machine-wide cap silently becomes N × budget. + */ +export function autoMaxLiveWorkers(resources: WorkerResources = detectWorkerResources(), daemonCount = 1): number { const cpuBudget = Math.max(1, resources.cpuCount) * 2; const memoryBudget = Math.max(1, Math.round(resources.memoryBytes / 1024 ** 3)); - return clamp(Math.min(cpuBudget, memoryBudget), MIN_AUTO_MAX_LIVE_WORKERS, MAX_AUTO_MAX_LIVE_WORKERS); + const machineBudget = Math.min(cpuBudget, memoryBudget); + const perDaemon = Math.floor(machineBudget / Math.max(1, daemonCount)); + return clamp(perDaemon, MIN_AUTO_MAX_LIVE_WORKERS, MAX_AUTO_MAX_LIVE_WORKERS); } export function resolveWorkerBudget( workerConfig?: WorkerConfig, resources: WorkerResources = detectWorkerResources(), + daemonCount = 1, ): ResolvedWorkerBudget { - const auto = autoMaxLiveWorkers(resources); + const auto = autoMaxLiveWorkers(resources, daemonCount); return { maxLiveWorkers: workerConfig?.maxLiveWorkers ?? auto, idleSuspendMs: workerConfig?.idleSuspendMs ?? DEFAULT_IDLE_SUSPEND_MS, diff --git a/test/worker-budget.test.ts b/test/worker-budget.test.ts index 35b6474a..d1814de9 100644 --- a/test/worker-budget.test.ts +++ b/test/worker-budget.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { resolveWorkerBudget } from '../src/core/worker-budget.js'; +import { autoMaxLiveWorkers, resolveWorkerBudget } from '../src/core/worker-budget.js'; const gib = (n: number) => n * 1024 ** 3; @@ -11,6 +11,27 @@ describe('resolveWorkerBudget', () => { expect(resolveWorkerBudget(undefined, { cpuCount: 64, memoryBytes: gib(128) }).maxLiveWorkers).toBe(32); }); + it('splits the machine budget across daemons sharing the box', () => { + const box = { cpuCount: 56, memoryBytes: gib(110) }; + // Single daemon: clamp(min(112, 110)) = 32 — unchanged legacy behavior. + expect(autoMaxLiveWorkers(box)).toBe(32); + expect(autoMaxLiveWorkers(box, 1)).toBe(32); + // Six daemons (one bot each): floor(110 / 6) = 18 per daemon. + expect(autoMaxLiveWorkers(box, 6)).toBe(18); + expect(resolveWorkerBudget(undefined, box, 6).maxLiveWorkers).toBe(18); + // The per-daemon share never drops below the MIN floor… + expect(autoMaxLiveWorkers(box, 100)).toBe(4); + // …and a bogus count is treated as a single daemon. + expect(autoMaxLiveWorkers(box, 0)).toBe(32); + }); + + it('keeps an explicit maxLiveWorkers override per-daemon (not split)', () => { + const resolved = resolveWorkerBudget({ maxLiveWorkers: 12 }, { cpuCount: 56, memoryBytes: gib(110) }, 6); + expect(resolved.maxLiveWorkers).toBe(12); + expect(resolved.maxLiveWorkersSource).toBe('config'); + expect(resolved.autoMaxLiveWorkers).toBe(18); + }); + it('lets global config override max live workers and idle threshold independently', () => { const resolved = resolveWorkerBudget( { maxLiveWorkers: 12, idleSuspendMs: 45 * 60_000 }, From 85afddd235a783b3cf9f5e74bf5d0a0dd6a87538 Mon Sep 17 00:00:00 2001 From: "zhubowen.cc" Date: Fri, 12 Jun 2026 14:12:32 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(worker-budget):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8D=95=E4=B8=AA=20bot=20=E8=87=AA=E5=AE=9A=E4=B9=89=20live-w?= =?UTF-8?q?orker=20=E9=A2=84=E7=AE=97=EF=BC=8C=E8=A6=86=E7=9B=96=E5=9D=87?= =?UTF-8?q?=E6=91=8A=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 均摊默认值对「有的 bot 用得多、有的用得少」不够灵活,而按会话总额算又会 出现多 bot 间强占。改为预设兜底 + 可选覆盖:每个 bot 可在 bots.json 自己的 项里配 worker { maxLiveWorkers / idleSuspendMs },按需单独加额度。 优先级:per-bot worker > 全局 config.json.worker > 均摊 auto 值。已配的 bot 用自己的值(走 config 源,不参与均摊、不被扣减);未配字段逐字段回落 到全局,再回落到均摊后的 auto baseline。简单版:未配 bot 仍按整机预算 ÷ bot 数均摊,不从已配 bot 扣减(整机理论可能略超,换取实现内聚 + 无强占)。 - bot-registry: BotConfig 新增可选 worker;parseBotConfigsFromText 复用 global-config.readWorker 同口径校验解析;新增 ownBotWorkerConfig() 取 本 daemon(一进程一 bot)的覆盖 - global-config: readWorker 导出以复用校验 - idle sweeper: { ...global.worker, ...ownBotWorkerConfig() } 字段级覆盖 后传入 resolveWorkerBudget;worker-budget.ts 未改(已有 ?? 优先级链) --- src/bot-registry.ts | 30 +++++++++++++++ src/core/idle-worker-sweeper.ts | 9 ++++- src/global-config.ts | 2 +- test/bot-registry.test.ts | 54 +++++++++++++++++++++++++++ test/idle-worker-sweeper.test.ts | 63 +++++++++++++++++++++++++++++++- 5 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/bot-registry.ts b/src/bot-registry.ts index e9e612fa..b9ecc2b1 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -6,6 +6,7 @@ import type { BackendType } from './adapters/backend/types.js'; import type { CliId } from './adapters/cli/types.js'; import { logger } from './utils/logger.js'; import { isLocale, setBotLookup, type Locale } from './i18n/index.js'; +import { readWorker, type WorkerConfig } from './global-config.js'; import type { VoiceConfig } from './services/voice/types.js'; import { type Brand, sdkDomain, normalizeBrand } from './im/lark/lark-hosts.js'; @@ -252,6 +253,16 @@ export interface BotConfig { * cards render the "🔊 语音总结" button. See services/voice/types.ts. */ voice?: VoiceConfig; + /** + * Per-bot live-worker budget override. Fields set here win over the global + * `worker` block in ~/.botmux/config.json for THIS bot's idle sweeper, and + * are NOT subject to the per-daemon auto split — a bot that configures + * `maxLiveWorkers` gets exactly that many, regardless of how many bots share + * the box. Unset fields fall through to the global config, then to the + * auto-derived (machine-budget ÷ bot-count) baseline. Use it to give a + * heavily-used bot a bigger slice without raising every other bot's cap. + */ + worker?: WorkerConfig; } export interface BotState { @@ -480,6 +491,20 @@ export function countConfiguredBots(): number { return Math.max(1, bots.size); } +/** + * This daemon's own bot's per-bot worker override, if any. A daemon process + * hosts exactly one bot (see daemon.ts — registerBot is called once per + * BOTMUX_BOT_INDEX), so the in-memory registry has a single entry whose + * `worker` block, when present, governs this bot's idle sweeper. Returns + * undefined when unconfigured (caller falls back to global + auto budget). + */ +export function ownBotWorkerConfig(): WorkerConfig | undefined { + for (const bot of bots.values()) { + if (bot.config.worker) return bot.config.worker; + } + return undefined; +} + /** * Load bot configurations from one of (in priority order): * 1. BOTS_CONFIG env var — path to a JSON file @@ -679,6 +704,10 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { } } + // worker:per-bot live-worker 预算覆盖。复用 global-config.readWorker 同口径 + // 校验(maxLiveWorkers/idleSuspendMs 仅取正整数,非法/缺省 → undefined)。 + const worker = readWorker(entry.worker); + configs.push({ larkAppId: entry.larkAppId, larkAppSecret: entry.larkAppSecret, @@ -746,6 +775,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { ? entry.regularGroupMentionMode : undefined, voice, + worker, }); } diff --git a/src/core/idle-worker-sweeper.ts b/src/core/idle-worker-sweeper.ts index 5ced96e7..b8898967 100644 --- a/src/core/idle-worker-sweeper.ts +++ b/src/core/idle-worker-sweeper.ts @@ -1,5 +1,5 @@ import type { DaemonSession } from './types.js'; -import { countConfiguredBots } from '../bot-registry.js'; +import { countConfiguredBots, ownBotWorkerConfig } from '../bot-registry.js'; import { readGlobalConfig } from '../global-config.js'; import { DEFAULT_IDLE_SUSPEND_MS, resolveWorkerBudget, type ResolvedWorkerBudget } from './worker-budget.js'; import { suspendWorker } from './worker-pool.js'; @@ -26,7 +26,12 @@ export function sweepIdleWorkers( opts: IdleWorkerSweepOptions = {}, ): IdleWorkerSweepResult[] { const now = opts.now ?? Date.now(); - const budget = opts.workerBudget ?? resolveWorkerBudget(readGlobalConfig().worker, undefined, countConfiguredBots()); + // Per-bot worker override (if this bot configured one) wins field-by-field + // over the machine-wide global block; unset fields fall through to the + // per-daemon auto split inside resolveWorkerBudget. An explicit per-bot + // maxLiveWorkers therefore bypasses the split entirely (config source). + const workerConfig = { ...readGlobalConfig().worker, ...ownBotWorkerConfig() }; + const budget = opts.workerBudget ?? resolveWorkerBudget(workerConfig, undefined, countConfiguredBots()); const maxLiveWorkers = budget.maxLiveWorkers; const idleMs = budget.idleSuspendMs; const running = liveWorkers(activeSessions); diff --git a/src/global-config.ts b/src/global-config.ts index f72caaae..3a8bf8cd 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -170,7 +170,7 @@ function readPositiveInteger(raw: unknown): number | undefined { return raw; } -function readWorker(raw: unknown): WorkerConfig | undefined { +export function readWorker(raw: unknown): WorkerConfig | undefined { if (!raw || typeof raw !== 'object') return undefined; const v = raw as Record; const worker: WorkerConfig = {}; diff --git a/test/bot-registry.test.ts b/test/bot-registry.test.ts index a4fe2b63..ecf24a9b 100644 --- a/test/bot-registry.test.ts +++ b/test/bot-registry.test.ts @@ -143,6 +143,60 @@ describe('parseBotConfigsFromText — brand', () => { }); }); +// ─── per-bot worker parsing ───────────────────────────────────────────────── + +describe('parseBotConfigsFromText — per-bot worker', () => { + let mod: Awaited>; + + beforeEach(async () => { + mod = await freshImport(); + }); + + it('parses a per-bot worker override of positive integers', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'a', larkAppSecret: 's', worker: { maxLiveWorkers: 30, idleSuspendMs: 600000 } }, + ])); + expect(cfg.worker).toEqual({ maxLiveWorkers: 30, idleSuspendMs: 600000 }); + }); + + it('leaves worker undefined when unset', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'a', larkAppSecret: 's' }, + ])); + expect(cfg.worker).toBeUndefined(); + }); + + it('drops non-positive / non-integer worker fields', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'a', larkAppSecret: 's', worker: { maxLiveWorkers: 0, idleSuspendMs: -5 } }, + ])); + expect(cfg.worker).toBeUndefined(); + }); + + it('keeps only the valid field when one is bogus', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'a', larkAppSecret: 's', worker: { maxLiveWorkers: 12, idleSuspendMs: 1.5 } }, + ])); + expect(cfg.worker).toEqual({ maxLiveWorkers: 12 }); + }); + + it('exposes this daemon bot worker override via ownBotWorkerConfig', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'a', larkAppSecret: 's', worker: { maxLiveWorkers: 24 } }, + ])); + mod.registerBot(cfg); + expect(mod.ownBotWorkerConfig()).toEqual({ maxLiveWorkers: 24 }); + }); + + it('ownBotWorkerConfig is undefined when the bot has no worker block', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'a', larkAppSecret: 's' }, + ])); + mod.registerBot(cfg); + expect(mod.ownBotWorkerConfig()).toBeUndefined(); + }); +}); + // ─── getBot / getBotClient ──────────────────────────────────────────────── describe('getBot / getBotClient', () => { diff --git a/test/idle-worker-sweeper.test.ts b/test/idle-worker-sweeper.test.ts index dae1e081..29255bb5 100644 --- a/test/idle-worker-sweeper.test.ts +++ b/test/idle-worker-sweeper.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../src/services/session-store.js', () => ({ updateSessionPid: vi.fn(), @@ -16,6 +16,16 @@ vi.mock('../src/utils/logger.js', () => ({ }, })); +const mockReadGlobalConfig = vi.fn(); +const mockOwnBotWorkerConfig = vi.fn(); +vi.mock('../src/global-config.js', () => ({ + readGlobalConfig: () => mockReadGlobalConfig() ?? {}, +})); +vi.mock('../src/bot-registry.js', () => ({ + countConfiguredBots: () => 6, + ownBotWorkerConfig: () => mockOwnBotWorkerConfig(), +})); + import { sweepIdleWorkers } from '../src/core/idle-worker-sweeper.js'; function ds(sessionId: string, backendType: string, lastMessageAt: number, worker = {}) { @@ -216,3 +226,54 @@ describe('sweepIdleWorkers', () => { expect(activeSessions.get('a').worker).not.toBe(null); }); }); + +describe('sweepIdleWorkers per-bot worker override', () => { + beforeEach(() => { + mockReadGlobalConfig.mockReset(); + mockOwnBotWorkerConfig.mockReset(); + }); + + const overBudget = (now: number) => new Map([ + ['a', ds('a', 'tmux', now - 90 * 60_000)], + ['b', ds('b', 'herdr', now - 80 * 60_000)], + ['c', ds('c', 'zellij', now - 70 * 60_000)], + ]); + + it("uses this bot's per-bot maxLiveWorkers over the global block (not auto-split)", () => { + const now = 1_000_000; + // Global says 1, but this bot configured 2 → its own value wins, so only + // the single oldest idle worker over its budget of 2 is suspended. + mockReadGlobalConfig.mockReturnValue({ worker: { maxLiveWorkers: 1, idleSuspendMs: 30 * 60_000 } }); + mockOwnBotWorkerConfig.mockReturnValue({ maxLiveWorkers: 2 }); + + const activeSessions = overBudget(now); + const suspended = sweepIdleWorkers(activeSessions, { now }); + + expect(suspended.map(s => s.sessionId)).toEqual(['a']); + }); + + it('falls through to the global block for fields the bot did not set', () => { + const now = 1_000_000; + // Bot only overrides idleSuspendMs; maxLiveWorkers comes from global (1). + mockReadGlobalConfig.mockReturnValue({ worker: { maxLiveWorkers: 1 } }); + mockOwnBotWorkerConfig.mockReturnValue({ idleSuspendMs: 30 * 60_000 }); + + const activeSessions = overBudget(now); + const suspended = sweepIdleWorkers(activeSessions, { now }); + + expect(suspended.map(s => s.sessionId)).toEqual(['a', 'b']); + }); + + it('keeps an idle worker live when its per-bot idleSuspendMs has not elapsed', () => { + const now = 1_000_000; + // High idle threshold (2h) → the 90/80-min-idle workers are too recent to + // suspend even though they exceed the budget of 1. + mockReadGlobalConfig.mockReturnValue({ worker: { maxLiveWorkers: 1 } }); + mockOwnBotWorkerConfig.mockReturnValue({ idleSuspendMs: 120 * 60_000 }); + + const activeSessions = overBudget(now); + const suspended = sweepIdleWorkers(activeSessions, { now }); + + expect(suspended).toEqual([]); + }); +});