Files
EJClaw/src/auto-paired-mode.ts
2026-05-27 10:53:31 +09:00

180 lines
5.7 KiB
TypeScript

/**
* 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<void>;
}
function loadState(): AutoPairedModeState {
const raw = readJsonFile<AutoPairedModeState>(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<string>();
// 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<void> {
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.
}