/** * Auto paired-mode degrade/restore. * * When all configured Claude accounts are at/over the 95% exhaustion threshold, * paired (tribunal) rooms cannot meaningfully run their reviewer/arbiter * because the gate blocks Claude turns. To keep the user-facing rooms * responsive, we temporarily flip selected rooms to `single` mode and notify * each affected channel. When usage recovers we flip them back to `tribunal`. * * Selection (Option B agreed with the user): * - The most-recently-active room (per chats.last_message_time) * - All rooms that are currently in-flight (queue status === 'processing') * - Deduplicated; rooms whose owner is `claude-code` are skipped because * degrading them does not help — the 95% gate blocks them either way. * * Restore behavior: * - Only restore a room if it is still in 'single' (don't override a manual * user change made during the degrade window). */ import fs from 'fs'; import path from 'path'; import { getClaudeUsageExhaustion } from './claude-usage.js'; import { DATA_DIR } from './config.js'; import { getAllChats, getAllRoomBindings, getEffectiveRoomMode, getStoredRoomSettings, setExplicitRoomMode, } from './db.js'; import { logger } from './logger.js'; import { readJsonFile, writeJsonFile } from './utils.js'; const STATE_PATH = path.join(DATA_DIR, 'auto-paired-mode-state.json'); const DEGRADE_NOTICE = '클로드 사용량이 95%를 넘어 잠시 single 모드로 전환했어요. 사용량이 회복되면 paired 모드로 다시 켤게요.'; const RESTORE_NOTICE = '클로드 사용량이 회복돼서 paired 모드로 다시 켰어요.'; interface DowngradeRecord { chatJid: string; atIso: string; } interface AutoPairedModeState { exhausted: boolean; downgraded: DowngradeRecord[]; } export interface AutoPairedModeDeps { queue?: { getStatuses(jids: string[]): Array<{ jid: string; status: string }>; }; sendNotice: (chatJid: string, text: string) => Promise; } function loadState(): AutoPairedModeState { const raw = readJsonFile(STATE_PATH); if (!raw || typeof raw !== 'object') { return { exhausted: false, downgraded: [] }; } return { exhausted: Boolean(raw.exhausted), downgraded: Array.isArray(raw.downgraded) ? raw.downgraded : [], }; } function saveState(state: AutoPairedModeState): void { try { fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }); writeJsonFile(STATE_PATH, state); } catch (err) { logger.warn({ err }, 'auto-paired-mode: failed to persist state'); } } function pickCandidates(deps: AutoPairedModeDeps): string[] { const bindings = getAllRoomBindings(); const allJids = Object.keys(bindings); if (allJids.length === 0) return []; const candidates = new Set(); // Most-recently-active room first (chats ordered DESC by last_message_time). const chats = getAllChats(); const recent = chats.find((c) => allJids.includes(c.jid)); if (recent) candidates.add(recent.jid); // Currently in-flight rooms. if (deps.queue) { for (const status of deps.queue.getStatuses(allJids)) { if (status.status === 'processing') candidates.add(status.jid); } } // Skip claude-owned rooms (Option B). const result: string[] = []; for (const jid of candidates) { const settings = getStoredRoomSettings(jid); if (settings?.ownerAgentType === 'claude-code') continue; if (getEffectiveRoomMode(jid) !== 'tribunal') continue; result.push(jid); } return result; } export async function evaluateAndApplyAutoPairedMode( deps: AutoPairedModeDeps, ): Promise { const exhaustion = getClaudeUsageExhaustion(); const state = loadState(); if (exhaustion.exhausted && !state.exhausted) { // Transition: healthy → exhausted. Downgrade selected rooms. const targets = pickCandidates(deps); const downgraded: DowngradeRecord[] = []; for (const jid of targets) { try { setExplicitRoomMode(jid, 'single'); downgraded.push({ chatJid: jid, atIso: new Date().toISOString() }); logger.info( { jid }, 'auto-paired-mode: downgraded room to single (claude exhausted)', ); try { await deps.sendNotice(jid, DEGRADE_NOTICE); } catch (err) { logger.warn( { err, jid }, 'auto-paired-mode: failed to send degrade notice', ); } } catch (err) { logger.warn({ err, jid }, 'auto-paired-mode: failed to downgrade room'); } } saveState({ exhausted: true, downgraded }); return; } if (!exhaustion.exhausted && state.exhausted) { // Transition: exhausted → healthy. Restore previously-downgraded rooms. for (const record of state.downgraded) { try { if (getEffectiveRoomMode(record.chatJid) !== 'single') { // Respect manual user changes during the degrade window. logger.info( { jid: record.chatJid }, 'auto-paired-mode: skip restore (room is no longer single)', ); continue; } setExplicitRoomMode(record.chatJid, 'tribunal'); logger.info( { jid: record.chatJid }, 'auto-paired-mode: restored room to tribunal', ); try { await deps.sendNotice(record.chatJid, RESTORE_NOTICE); } catch (err) { logger.warn( { err, jid: record.chatJid }, 'auto-paired-mode: failed to send restore notice', ); } } catch (err) { logger.warn( { err, jid: record.chatJid }, 'auto-paired-mode: failed to restore room', ); } } saveState({ exhausted: false, downgraded: [] }); return; } // Steady state — nothing to do. }