180 lines
5.7 KiB
TypeScript
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.
|
|
}
|