backup current stable ejclaw state
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"env": {
|
||||
"CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "50"
|
||||
"CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "70"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,95 @@ This file is for mutable memory shared across Claude groups.
|
||||
Use it for durable facts, preferences, and shared context that may change over time.
|
||||
|
||||
Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`.
|
||||
|
||||
## Stored credentials
|
||||
|
||||
Shared credentials live at `/home/claude/.config/ejclaw/secrets.json` (chmod 600, owner-only). Read with the Read tool when a session in any channel asks to "저장해둔 계정토큰으로 로그인" or otherwise needs a registered token.
|
||||
|
||||
Schema: `credentials.<host>.{type, host, token, note, added_at}`.
|
||||
|
||||
Currently stored:
|
||||
- `git.tkrmagid.kr` — Gitea personal access token. Use via `Authorization: token <value>` header, or embed in HTTPS URL as `https://<user>:<token>@git.tkrmagid.kr/...`. For `git clone/push`, prefer the URL form or `git -c http.extraHeader="Authorization: token <value>" clone ...`. Do not paste the raw token into chat replies.
|
||||
- `sudo` — local sudo password for the `claude` user on this host. Use via `echo "$PW" | sudo -S <cmd>` (read the password from `credentials.sudo.password` with the Read tool, then pipe). Do not paste the raw password into chat replies.
|
||||
|
||||
To add or rotate a credential, edit `secrets.json` and append a new entry under `credentials`; update this list with the host and intended use.
|
||||
|
||||
## Room mode policy
|
||||
|
||||
All rooms default to `tribunal` (paired) mode. Owner runs the work, reviewer/arbiter (claude-code) verifies. New rooms registered via `bun setup/index.ts --step register` are also `tribunal` by default.
|
||||
|
||||
If the user in a channel says any of these — "클로드 사용하지 말자", "paired 모드 끄자", "리뷰어 끄자", "이 방은 single 로 바꿔줘" — switch that channel back to `single` mode. Use:
|
||||
|
||||
```bash
|
||||
bun -e "import { initDatabase, setExplicitRoomMode } from './src/db.js'; initDatabase(); setExplicitRoomMode('<chatJid>', 'single');"
|
||||
```
|
||||
|
||||
The reverse phrase ("paired 켜자", "리뷰어 다시 켜자") flips it back to `'tribunal'`. Acknowledge the change in chat and confirm the new mode.
|
||||
|
||||
---
|
||||
|
||||
# CLAUDE.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
3859
package-lock.json
generated
Normal file
3859
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,8 @@ If you see a materially better design, debugging path, or scoping choice, propos
|
||||
- **BLOCKED** — Cannot proceed without user decision
|
||||
- **NEEDS_CONTEXT** — Missing information from user
|
||||
|
||||
Do not start with non-status review labels like **APPROVE** or **REVISE**. Use **TASK_DONE** for approval and **DONE_WITH_CONCERNS** for change requests.
|
||||
|
||||
## Rules
|
||||
|
||||
- Judge completion only by verification output. "It should work now" means run it. "I'm confident" means nothing — confidence is not evidence. "I tested earlier" means test again if code changed since. "It's a trivial change" means verify anyway
|
||||
|
||||
@@ -4,25 +4,3 @@ You have a `send_message` tool that sends a message immediately while you are st
|
||||
Use it to acknowledge a request before starting longer work.
|
||||
|
||||
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
|
||||
|
||||
## Image attachments
|
||||
|
||||
When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose:
|
||||
|
||||
```json
|
||||
{
|
||||
"ejclaw": {
|
||||
"visibility": "public",
|
||||
"text": "스크린샷을 첨부했습니다.",
|
||||
"attachments": [
|
||||
{
|
||||
"path": "/absolute/path/screenshot.png",
|
||||
"name": "screenshot.png",
|
||||
"mime": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||
|
||||
@@ -27,39 +27,3 @@ Your output is sent directly to the Discord group.
|
||||
- For CI/status/watch requests that require future follow-up, schedule `watch_ci`
|
||||
- Do not use generic recurring task registration from Codex
|
||||
- If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it
|
||||
|
||||
## Image attachments
|
||||
|
||||
When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`.
|
||||
|
||||
```json
|
||||
{
|
||||
"ejclaw": {
|
||||
"visibility": "public",
|
||||
"text": "이미지를 생성했습니다.",
|
||||
"verdict": "done",
|
||||
"attachments": [
|
||||
{
|
||||
"path": "/absolute/path/image.png",
|
||||
"name": "image.png",
|
||||
"mime": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `attachments.path` must be an absolute local path; URLs and relative paths are ignored
|
||||
- Do not repeat the same file path in the visible text
|
||||
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file
|
||||
|
||||
## CI 감시 (watch_ci)
|
||||
|
||||
GitHub Actions run 감시는 structured 필드를 우선 사용:
|
||||
|
||||
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
|
||||
- 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling)
|
||||
- structured 필드 없이 generic 등록 시 매 tick LLM 실행됨
|
||||
- ci_pr_number는 아직 미지원
|
||||
- GitHub 외 CI는 기존 generic 경로 사용
|
||||
|
||||
1
restart.sh
Normal file
1
restart.sh
Normal file
@@ -0,0 +1 @@
|
||||
systemctl --user restart ejclaw
|
||||
@@ -17,6 +17,7 @@ const STEPS: Record<
|
||||
'restart-stack': () => import('./restart-stack.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
login: () => import('./login.js'),
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
228
setup/login.ts
Normal file
228
setup/login.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Step: login — Sign in to Claude (Pro/Max) via OAuth PKCE manual flow.
|
||||
*
|
||||
* Mirrors the URL Claude Code's `claude auth login --claudeai` actually
|
||||
* builds (captured from the official CLI). This is the manual / paste-code
|
||||
* variant: the user visits the authorize URL, completes login in their
|
||||
* browser, and pastes the code back. We exchange it for tokens and write
|
||||
* `~/.claude/.credentials.json`.
|
||||
*
|
||||
* Usage:
|
||||
* bun setup/index.ts --step login # phase 1: print authorize URL
|
||||
* bun setup/index.ts --step login --code <c> # phase 2: exchange the code
|
||||
*
|
||||
* Phase 1 stashes the PKCE verifier + state in /tmp/ejclaw-claude-login.json
|
||||
* (mode 0600). Phase 2 reads it back, exchanges the code, writes credentials.
|
||||
*
|
||||
* This step is run by hand for re-auth. Once `.credentials.json` exists, the
|
||||
* existing token-refresh loop (src/token-refresh.ts) keeps it fresh.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { logger } from '../src/logger.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
const AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
|
||||
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
||||
const REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
|
||||
const SCOPES = [
|
||||
'org:create_api_key',
|
||||
'user:profile',
|
||||
'user:inference',
|
||||
'user:sessions:claude_code',
|
||||
'user:mcp_servers',
|
||||
'user:file_upload',
|
||||
];
|
||||
|
||||
const STATE_FILE = path.join(os.tmpdir(), 'ejclaw-claude-login.json');
|
||||
const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
|
||||
interface PendingState {
|
||||
verifier: string;
|
||||
state: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface ExchangeResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
account?: {
|
||||
email_address?: string;
|
||||
has_claude_max?: boolean;
|
||||
has_claude_pro?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf
|
||||
.toString('base64')
|
||||
.replace(/=+$/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = base64url(crypto.randomBytes(32));
|
||||
const challenge = base64url(
|
||||
crypto.createHash('sha256').update(verifier).digest(),
|
||||
);
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(state: string, challenge: string): string {
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.append('code', 'true');
|
||||
url.searchParams.append('client_id', CLIENT_ID);
|
||||
url.searchParams.append('response_type', 'code');
|
||||
url.searchParams.append('redirect_uri', REDIRECT_URI);
|
||||
url.searchParams.append('scope', SCOPES.join(' '));
|
||||
url.searchParams.append('code_challenge', challenge);
|
||||
url.searchParams.append('code_challenge_method', 'S256');
|
||||
url.searchParams.append('state', state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writePendingState(p: PendingState): void {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(p), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function readPendingState(): PendingState | null {
|
||||
if (!fs.existsSync(STATE_FILE)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PendingState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
state: string,
|
||||
): Promise<ExchangeResponse> {
|
||||
// The success page can hand the user a value of `code#state` or just
|
||||
// `code` — accept both.
|
||||
const [bareCode, embeddedState] = code.split('#');
|
||||
const stateForExchange = embeddedState || state;
|
||||
const body = JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
code: bareCode,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: verifier,
|
||||
state: stateForExchange,
|
||||
});
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Token exchange failed (${res.status}): ${text.slice(0, 400)}`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as ExchangeResponse;
|
||||
}
|
||||
|
||||
function writeCredentials(resp: ExchangeResponse): void {
|
||||
const expiresAt = Date.now() + resp.expires_in * 1000;
|
||||
const creds = {
|
||||
claudeAiOauth: {
|
||||
accessToken: resp.access_token,
|
||||
refreshToken: resp.refresh_token || '',
|
||||
expiresAt,
|
||||
scopes: resp.scope ? resp.scope.split(' ') : SCOPES,
|
||||
subscriptionType: resp.account?.has_claude_max
|
||||
? 'max'
|
||||
: resp.account?.has_claude_pro
|
||||
? 'pro'
|
||||
: '',
|
||||
},
|
||||
};
|
||||
const dir = path.dirname(CREDS_PATH);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const tmp = `${CREDS_PATH}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, CREDS_PATH);
|
||||
}
|
||||
|
||||
interface Args {
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Args {
|
||||
let code: string | null = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--code' && args[i + 1]) {
|
||||
code = args[++i];
|
||||
}
|
||||
}
|
||||
return { code };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { code } = parseArgs(args);
|
||||
|
||||
if (!code) {
|
||||
// Phase 1: produce the authorize URL.
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = base64url(crypto.randomBytes(32));
|
||||
writePendingState({ verifier, state, createdAt: Date.now() });
|
||||
const url = buildAuthorizeUrl(state, challenge);
|
||||
|
||||
console.log('AUTHORIZE_URL=' + url);
|
||||
console.log(
|
||||
'NEXT: open the URL above in your browser, complete the login,' +
|
||||
' and re-run this command with --code <pasted_code>.',
|
||||
);
|
||||
emitStatus('LOGIN_URL', { STATUS: 'awaiting_code', URL: url });
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: exchange the code.
|
||||
const pending = readPendingState();
|
||||
if (!pending) {
|
||||
emitStatus('LOGIN', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'no_pending_state — run phase 1 first',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
if (Date.now() - pending.createdAt > 30 * 60 * 1000) {
|
||||
logger.warn(
|
||||
'Pending login state is older than 30 minutes — proceeding anyway',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await exchangeCode(code, pending.verifier, pending.state);
|
||||
writeCredentials(resp);
|
||||
fs.unlinkSync(STATE_FILE);
|
||||
const newScopes = (resp.scope || SCOPES.join(' ')).split(' ').sort();
|
||||
logger.info(
|
||||
{ scopes: newScopes, expiresInMin: Math.round(resp.expires_in / 60) },
|
||||
'Wrote ~/.claude/.credentials.json',
|
||||
);
|
||||
emitStatus('LOGIN', {
|
||||
STATUS: 'success',
|
||||
CREDENTIALS_PATH: CREDS_PATH,
|
||||
SCOPES: newScopes.join(','),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error({ err: message }, 'Token exchange failed');
|
||||
emitStatus('LOGIN', { STATUS: 'failed', ERROR: message });
|
||||
process.exit(4);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* EJClaw is Discord-only, so registrations must target Discord channel IDs.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -12,6 +13,45 @@ import { isValidGroupFolder } from '../src/group-folder.js';
|
||||
import { logger } from '../src/logger.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
/**
|
||||
* Resolve a usable git-repo work_dir for a new tribunal room.
|
||||
* Order: <folder> if .git exists, else <folder>/repo if .git exists,
|
||||
* else `git init` <folder> (with an empty initial commit) and use it.
|
||||
* Returns the absolute work_dir path.
|
||||
*/
|
||||
function ensureWorkDirForFolder(folderName: string): string {
|
||||
const folderPath = path.join(GROUPS_DIR, folderName);
|
||||
fs.mkdirSync(folderPath, { recursive: true });
|
||||
|
||||
if (fs.existsSync(path.join(folderPath, '.git'))) {
|
||||
return folderPath;
|
||||
}
|
||||
const repoSub = path.join(folderPath, 'repo');
|
||||
if (fs.existsSync(path.join(repoSub, '.git'))) {
|
||||
return repoSub;
|
||||
}
|
||||
|
||||
// No git repo found anywhere — initialize at the folder root.
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'ejclaw',
|
||||
GIT_AUTHOR_EMAIL: 'ejclaw@local',
|
||||
GIT_COMMITTER_NAME: 'ejclaw',
|
||||
GIT_COMMITTER_EMAIL: 'ejclaw@local',
|
||||
};
|
||||
execFileSync('git', ['init', '-q'], { cwd: folderPath, env: gitEnv });
|
||||
execFileSync(
|
||||
'git',
|
||||
['commit', '-q', '--allow-empty', '-m', `init ${folderName} workspace`],
|
||||
{ cwd: folderPath, env: gitEnv },
|
||||
);
|
||||
logger.info(
|
||||
{ folder: folderName, workDir: folderPath },
|
||||
'Initialized git repository for tribunal workspace',
|
||||
);
|
||||
return folderPath;
|
||||
}
|
||||
|
||||
interface RegisterArgs {
|
||||
jid: string;
|
||||
name: string;
|
||||
@@ -102,10 +142,20 @@ export async function run(args: string[]): Promise<void> {
|
||||
logger.info(parsed, 'Registering channel');
|
||||
|
||||
initDatabase();
|
||||
|
||||
// Defaults for newly registered rooms:
|
||||
// roomMode = tribunal, owner = claude-code, reviewer = codex
|
||||
// Tribunal rooms also require a git-backed work_dir; auto-init if missing.
|
||||
const workDir = ensureWorkDirForFolder(parsed.folder);
|
||||
|
||||
assignRoom(parsed.jid, {
|
||||
name: parsed.name,
|
||||
folder: parsed.folder,
|
||||
isMain: parsed.isMain,
|
||||
roomMode: 'tribunal',
|
||||
ownerAgentType: 'claude-code',
|
||||
reviewerAgentType: 'codex',
|
||||
workDir,
|
||||
});
|
||||
logger.info('Assigned room through canonical room service');
|
||||
|
||||
|
||||
@@ -55,6 +55,17 @@ describe('agent-error-detection', () => {
|
||||
expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded');
|
||||
});
|
||||
|
||||
it('classifies Claude 500 API banners as overloaded provider failures', () => {
|
||||
const message =
|
||||
'API Error: 500 Internal server error. This is a server-side issue, usually temporary — try again in a moment. If it persists, check status.claude.com.';
|
||||
|
||||
expect(classifyAgentError(message)).toEqual({
|
||||
category: 'overloaded',
|
||||
reason: 'overloaded',
|
||||
});
|
||||
expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded');
|
||||
});
|
||||
|
||||
it('marks only Claude quota/auth reasons as Claude rotation reasons', () => {
|
||||
expect(shouldRotateClaudeToken('429')).toBe(true);
|
||||
expect(shouldRotateClaudeToken('usage-exhausted')).toBe(true);
|
||||
|
||||
@@ -260,8 +260,11 @@ export function classifyAgentError(
|
||||
return { category: 'rate-limit', reason: '429', retryAfterMs };
|
||||
}
|
||||
|
||||
// 503 / Overloaded
|
||||
const hasApi5xx = /\bapi error:\s*5\d\d\b/i.test(error);
|
||||
|
||||
// 5xx / Overloaded
|
||||
if (
|
||||
hasApi5xx ||
|
||||
lower.includes('503') ||
|
||||
lower.includes('overloaded') ||
|
||||
((lower.includes('502') || lower.includes('bad gateway')) &&
|
||||
|
||||
@@ -368,6 +368,67 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
||||
|
||||
expect(segments).toEqual(['platform prompt']);
|
||||
});
|
||||
|
||||
it('applies per-role codex model overrides when roomRole is supplied', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
|
||||
const groupWithByRole: RegisteredGroup = {
|
||||
...group,
|
||||
agentType: 'codex',
|
||||
agentConfig: {
|
||||
codexModel: 'gpt-5-sonnet',
|
||||
codexEffort: 'medium',
|
||||
codexModelByRole: { arbiter: 'gpt-5-opus' },
|
||||
codexEffortByRole: { arbiter: 'high' },
|
||||
},
|
||||
};
|
||||
|
||||
const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
|
||||
roomRole: 'arbiter',
|
||||
});
|
||||
expect(arbiter.env.CODEX_MODEL).toBe('gpt-5-opus');
|
||||
expect(arbiter.env.CODEX_EFFORT).toBe('high');
|
||||
|
||||
const owner = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
|
||||
roomRole: 'owner',
|
||||
});
|
||||
expect(owner.env.CODEX_MODEL).toBe('gpt-5-sonnet');
|
||||
expect(owner.env.CODEX_EFFORT).toBe('medium');
|
||||
|
||||
const noRole = prepareGroupEnvironment(groupWithByRole, false, 'dc:test');
|
||||
expect(noRole.env.CODEX_MODEL).toBe('gpt-5-sonnet');
|
||||
expect(noRole.env.CODEX_EFFORT).toBe('medium');
|
||||
});
|
||||
|
||||
it('applies per-role claude model overrides when roomRole is supplied', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
|
||||
const groupWithByRole: RegisteredGroup = {
|
||||
...group,
|
||||
agentType: 'claude-code',
|
||||
agentConfig: {
|
||||
claudeModel: 'claude-sonnet-4-6',
|
||||
claudeEffort: 'medium',
|
||||
claudeModelByRole: { arbiter: 'claude-opus-4-7' },
|
||||
claudeEffortByRole: { arbiter: 'high' },
|
||||
},
|
||||
};
|
||||
|
||||
const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', {
|
||||
roomRole: 'arbiter',
|
||||
});
|
||||
expect(arbiter.env.CLAUDE_MODEL).toBe('claude-opus-4-7');
|
||||
expect(arbiter.env.CLAUDE_EFFORT).toBe('high');
|
||||
|
||||
const reviewer = prepareGroupEnvironment(
|
||||
groupWithByRole,
|
||||
false,
|
||||
'dc:test',
|
||||
{ roomRole: 'reviewer' },
|
||||
);
|
||||
expect(reviewer.env.CLAUDE_MODEL).toBe('claude-sonnet-4-6');
|
||||
expect(reviewer.env.CLAUDE_EFFORT).toBe('medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareReadonlySessionEnvironment codex compatibility', () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
getEffectiveChannelLease,
|
||||
hasReviewerLease,
|
||||
} from './service-routing.js';
|
||||
import type { AgentType, RegisteredGroup } from './types.js';
|
||||
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
|
||||
|
||||
// writeCodexApiKeyAuth removed — Codex uses OAuth only.
|
||||
// API key auth caused unintended billing.
|
||||
@@ -232,6 +232,7 @@ function prepareClaudeEnvironment(args: {
|
||||
env: Record<string, string>;
|
||||
envVars: Record<string, string>;
|
||||
group: RegisteredGroup;
|
||||
roomRole?: PairedRoomRole;
|
||||
}): void {
|
||||
if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
|
||||
args.env.ANTHROPIC_API_KEY =
|
||||
@@ -279,6 +280,15 @@ function prepareClaudeEnvironment(args: {
|
||||
if (args.group.agentConfig?.claudeEffort) {
|
||||
args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort;
|
||||
}
|
||||
// Per-role overrides (paired room): take precedence over the flat values.
|
||||
if (args.roomRole) {
|
||||
const modelByRole =
|
||||
args.group.agentConfig?.claudeModelByRole?.[args.roomRole];
|
||||
if (modelByRole) args.env.CLAUDE_MODEL = modelByRole;
|
||||
const effortByRole =
|
||||
args.group.agentConfig?.claudeEffortByRole?.[args.roomRole];
|
||||
if (effortByRole) args.env.CLAUDE_EFFORT = effortByRole;
|
||||
}
|
||||
if (args.group.agentConfig?.claudeThinking) {
|
||||
args.env.CLAUDE_THINKING = args.group.agentConfig.claudeThinking;
|
||||
}
|
||||
@@ -301,19 +311,29 @@ function prepareCodexSessionEnvironment(args: {
|
||||
isPairedRoom: boolean;
|
||||
useFailoverPromptPack: boolean;
|
||||
memoryBriefing?: string;
|
||||
roomRole?: PairedRoomRole;
|
||||
}): void {
|
||||
// API key auth intentionally removed — Codex uses OAuth only.
|
||||
// Never pass any API key to Codex child process to prevent API billing.
|
||||
delete args.env.OPENAI_API_KEY;
|
||||
delete args.env.CODEX_OPENAI_API_KEY;
|
||||
|
||||
// Per-role overrides (paired room) win over flat config which wins over env.
|
||||
const codexModelByRole = args.roomRole
|
||||
? args.group.agentConfig?.codexModelByRole?.[args.roomRole]
|
||||
: undefined;
|
||||
const codexModel =
|
||||
codexModelByRole ||
|
||||
args.group.agentConfig?.codexModel ||
|
||||
args.envVars.CODEX_MODEL ||
|
||||
process.env.CODEX_MODEL;
|
||||
if (codexModel) args.env.CODEX_MODEL = codexModel;
|
||||
|
||||
const codexEffortByRole = args.roomRole
|
||||
? args.group.agentConfig?.codexEffortByRole?.[args.roomRole]
|
||||
: undefined;
|
||||
const codexEffort =
|
||||
codexEffortByRole ||
|
||||
args.group.agentConfig?.codexEffort ||
|
||||
args.envVars.CODEX_EFFORT ||
|
||||
process.env.CODEX_EFFORT;
|
||||
@@ -338,6 +358,23 @@ function prepareCodexSessionEnvironment(args: {
|
||||
}
|
||||
|
||||
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
|
||||
const codexGlobalDir = path.join(GROUPS_DIR, 'global');
|
||||
const codexGlobalClaudeMdPath = path.join(codexGlobalDir, 'CLAUDE.md');
|
||||
const codexGlobalMemory =
|
||||
!args.isMain && fs.existsSync(codexGlobalClaudeMdPath)
|
||||
? fs.readFileSync(codexGlobalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const codexGroupClaudeMdPath = path.join(
|
||||
GROUPS_DIR,
|
||||
args.group.folder,
|
||||
'CLAUDE.md',
|
||||
);
|
||||
const codexGroupMemory =
|
||||
args.group.folder !== 'global' &&
|
||||
args.group.folder !== 'main' &&
|
||||
fs.existsSync(codexGroupClaudeMdPath)
|
||||
? fs.readFileSync(codexGroupClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const sessionAgents = (
|
||||
args.useFailoverPromptPack
|
||||
? [
|
||||
@@ -352,9 +389,12 @@ function prepareCodexSessionEnvironment(args: {
|
||||
'owner-common-paired-room.md',
|
||||
)
|
||||
: undefined,
|
||||
codexGlobalMemory,
|
||||
codexGroupMemory,
|
||||
args.memoryBriefing,
|
||||
]
|
||||
: [
|
||||
readOptionalPromptFile(args.projectRoot, 'owner-common-platform.md'),
|
||||
readPlatformPrompt('codex', args.projectRoot),
|
||||
args.isPairedRoom
|
||||
? readOptionalPromptFile(
|
||||
@@ -362,6 +402,8 @@ function prepareCodexSessionEnvironment(args: {
|
||||
'owner-common-paired-room.md',
|
||||
)
|
||||
: undefined,
|
||||
codexGlobalMemory,
|
||||
codexGroupMemory,
|
||||
args.memoryBriefing,
|
||||
]
|
||||
)
|
||||
@@ -429,6 +471,13 @@ export function prepareGroupEnvironment(
|
||||
memoryBriefing?: string;
|
||||
runtimeTaskId?: string;
|
||||
useTaskScopedSession?: boolean;
|
||||
/**
|
||||
* When provided, the runner consults `agentConfig.{claudeModel,claudeEffort,
|
||||
* codexModel,codexEffort}ByRole[roomRole]` and lets those entries override
|
||||
* the flat per-group values. Used by paired rooms to spend the expensive
|
||||
* model on a specific role (typically arbiter) only.
|
||||
*/
|
||||
roomRole?: PairedRoomRole;
|
||||
},
|
||||
): PreparedGroupEnvironment {
|
||||
const projectRoot = process.cwd();
|
||||
@@ -494,6 +543,13 @@ export function prepareGroupEnvironment(
|
||||
!isMain && fs.existsSync(globalClaudeMdPath)
|
||||
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const groupClaudeMdPath = path.join(GROUPS_DIR, group.folder, 'CLAUDE.md');
|
||||
const groupClaudeMemory =
|
||||
group.folder !== 'global' &&
|
||||
group.folder !== 'main' &&
|
||||
fs.existsSync(groupClaudeMdPath)
|
||||
? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
// Owner CLAUDE.md: platform rules + owner paired room rules.
|
||||
// Reviewer paired room rules are NOT included — those belong to the
|
||||
// Read-only reviewer/arbiter session only (via prepareReadonlySessionEnvironment).
|
||||
@@ -502,6 +558,7 @@ export function prepareGroupEnvironment(
|
||||
claudePlatformPrompt,
|
||||
ownerCommonPairedRoomPrompt,
|
||||
globalClaudeMemory,
|
||||
groupClaudeMemory,
|
||||
options?.memoryBriefing,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
@@ -559,9 +616,15 @@ export function prepareGroupEnvironment(
|
||||
isPairedRoom,
|
||||
useFailoverPromptPack: useCodexReviewFailoverPromptPack,
|
||||
memoryBriefing: options?.memoryBriefing,
|
||||
roomRole: options?.roomRole,
|
||||
});
|
||||
} else {
|
||||
prepareClaudeEnvironment({ env, envVars, group });
|
||||
prepareClaudeEnvironment({
|
||||
env,
|
||||
envVars,
|
||||
group,
|
||||
roomRole: options?.roomRole,
|
||||
});
|
||||
}
|
||||
|
||||
return { env, groupDir, runnerDir };
|
||||
@@ -626,11 +689,19 @@ export function prepareReadonlySessionEnvironment(args: {
|
||||
!isMain && fs.existsSync(globalClaudeMdPath)
|
||||
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
const groupClaudeMdPath = path.join(GROUPS_DIR, groupFolder, 'CLAUDE.md');
|
||||
const groupClaudeMemory =
|
||||
groupFolder !== 'global' &&
|
||||
groupFolder !== 'main' &&
|
||||
fs.existsSync(groupClaudeMdPath)
|
||||
? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim()
|
||||
: undefined;
|
||||
|
||||
const sessionClaudeMd = [
|
||||
claudePlatformPrompt,
|
||||
claudePairedRoomPrompt,
|
||||
globalClaudeMemory,
|
||||
groupClaudeMemory,
|
||||
memoryBriefing,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
@@ -61,6 +61,11 @@ export function runSpawnedAgentProcess(
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
|
||||
// Use UTF-8 string decoding on streams to avoid splitting multi-byte
|
||||
// characters (e.g. Korean) at chunk boundaries, which produces U+FFFD.
|
||||
stdoutStream.setEncoding('utf8');
|
||||
stderrStream.setEncoding('utf8');
|
||||
|
||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive.
|
||||
let parseBuffer = '';
|
||||
let newSessionId: string | undefined;
|
||||
|
||||
@@ -73,6 +73,7 @@ export async function runAgentProcess(
|
||||
memoryBriefing: input.memoryBriefing,
|
||||
runtimeTaskId: input.runtimeTaskId,
|
||||
useTaskScopedSession: input.useTaskScopedSession,
|
||||
roomRole: input.roomRoleContext?.role,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
179
src/auto-paired-mode.ts
Normal file
179
src/auto-paired-mode.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
@@ -23,7 +23,11 @@ import { extractImageTagPaths } from '../agent-protocol.js';
|
||||
import { validateOutboundAttachments } from '../outbound-attachments.js';
|
||||
import { formatOutbound } from '../router.js';
|
||||
import { hasReviewerLease } from '../service-routing.js';
|
||||
import type { OutboundAttachment, SendMessageOptions } from '../types.js';
|
||||
import type {
|
||||
EditMessageOptions,
|
||||
OutboundAttachment,
|
||||
SendMessageOptions,
|
||||
} from '../types.js';
|
||||
|
||||
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
|
||||
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
|
||||
@@ -514,7 +518,7 @@ export class DiscordChannel implements Channel {
|
||||
for (const [name, id] of Object.entries(mentionMap)) {
|
||||
cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`);
|
||||
}
|
||||
cleaned = formatOutbound(cleaned);
|
||||
cleaned = options.rawMarkdown ? cleaned : formatOutbound(cleaned);
|
||||
|
||||
// Discord has a 2000 character limit per message and 10 attachments per message
|
||||
const MAX_LENGTH = 2000;
|
||||
@@ -561,9 +565,21 @@ export class DiscordChannel implements Channel {
|
||||
}
|
||||
} else {
|
||||
// Send text in chunks, attach first batch to the first chunk
|
||||
// Use a helper that avoids splitting surrogate pairs (emoji, etc.)
|
||||
let fileBatchIndex = 0;
|
||||
for (let i = 0; i < cleaned.length; i += MAX_LENGTH) {
|
||||
const chunk = cleaned.slice(i, i + MAX_LENGTH);
|
||||
for (let i = 0; i < cleaned.length; ) {
|
||||
let end = Math.min(i + MAX_LENGTH, cleaned.length);
|
||||
// If we'd split a surrogate pair, back up one code unit
|
||||
if (
|
||||
end < cleaned.length &&
|
||||
end > i &&
|
||||
cleaned.charCodeAt(end - 1) >= 0xd800 &&
|
||||
cleaned.charCodeAt(end - 1) <= 0xdbff
|
||||
) {
|
||||
end--;
|
||||
}
|
||||
const chunk = cleaned.slice(i, end);
|
||||
i = end;
|
||||
const batch = fileBatches[fileBatchIndex];
|
||||
recordSentMessage(
|
||||
await textChannel.send({
|
||||
@@ -678,13 +694,18 @@ export class DiscordChannel implements Channel {
|
||||
);
|
||||
}
|
||||
|
||||
async sendAndTrack(jid: string, text: string): Promise<string | null> {
|
||||
async sendAndTrack(
|
||||
jid: string,
|
||||
text: string,
|
||||
options: EditMessageOptions = {},
|
||||
): Promise<string | null> {
|
||||
if (!this.client) return null;
|
||||
try {
|
||||
const channelId = jid.replace(/^dc:/, '');
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (!channel || !('send' in channel)) return null;
|
||||
const msg = await (channel as TextChannel).send(text);
|
||||
const cleaned = options.rawMarkdown ? text : formatOutbound(text);
|
||||
const msg = await (channel as TextChannel).send(cleaned);
|
||||
logger.info(
|
||||
{
|
||||
jid,
|
||||
@@ -693,7 +714,7 @@ export class DiscordChannel implements Channel {
|
||||
messageId: msg.id,
|
||||
botUserId: this.client.user?.id ?? null,
|
||||
botUsername: this.client.user?.username ?? null,
|
||||
length: text.length,
|
||||
length: cleaned.length,
|
||||
},
|
||||
'Discord tracked message sent',
|
||||
);
|
||||
@@ -814,6 +835,7 @@ export class DiscordChannel implements Channel {
|
||||
jid: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
options: EditMessageOptions = {},
|
||||
): Promise<void> {
|
||||
if (!this.client) return;
|
||||
try {
|
||||
@@ -821,14 +843,20 @@ export class DiscordChannel implements Channel {
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (!channel || !('messages' in channel)) return;
|
||||
const msg = await (channel as TextChannel).messages.fetch(messageId);
|
||||
await msg.edit(text);
|
||||
// Run the same sanitization pipeline as sendMessage so streaming
|
||||
// edits do not bypass markdown neutralization (backticks, headings,
|
||||
// italics, etc. that Discord would otherwise render and mangle).
|
||||
// Dashboard callers opt out with rawMarkdown: true to preserve
|
||||
// intentional bold/italic section headers.
|
||||
const cleaned = options.rawMarkdown ? text : formatOutbound(text);
|
||||
await msg.edit(cleaned);
|
||||
logger.info(
|
||||
{
|
||||
jid,
|
||||
channelName: this.name,
|
||||
deliveryMode: 'edit',
|
||||
messageId,
|
||||
length: text.length,
|
||||
length: cleaned.length,
|
||||
botUserId: this.client.user?.id ?? null,
|
||||
botUsername: this.client.user?.username ?? null,
|
||||
},
|
||||
|
||||
@@ -112,12 +112,15 @@ export function getUsageCacheReadKeys(
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Rate limit: at most one API call per token per 5 minutes
|
||||
const MIN_FETCH_INTERVAL_MS = 300_000;
|
||||
// Rate limit: at most one API call per token per 1 minute.
|
||||
// We poll usage every minute (was 5) so the 95% exhaustion gate has tight
|
||||
// resolution — at worst the gate fires ~60s after a window crosses 95%.
|
||||
const MIN_FETCH_INTERVAL_MS = 60_000;
|
||||
|
||||
async function fetchUsageForToken(
|
||||
token: string,
|
||||
accountIndex?: number,
|
||||
refreshAttempted = false,
|
||||
): Promise<ClaudeUsageData | null> {
|
||||
loadUsageDiskCache();
|
||||
|
||||
@@ -148,7 +151,11 @@ async function fetchUsageForToken(
|
||||
saveUsageDiskCache();
|
||||
}
|
||||
const lastAttempt = cached?.lastAttemptAt ?? cached?.fetchedAt ?? 0;
|
||||
if (cached && Date.now() - lastAttempt < MIN_FETCH_INTERVAL_MS) {
|
||||
if (
|
||||
!refreshAttempted &&
|
||||
cached &&
|
||||
Date.now() - lastAttempt < MIN_FETCH_INTERVAL_MS
|
||||
) {
|
||||
return cached.usage;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
@@ -165,16 +172,47 @@ async function fetchUsageForToken(
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
// 401 = token expired; 403 = token lacks `user:profile` scope.
|
||||
// Both are recoverable by exchanging refresh_token for a fresh access
|
||||
// token (mint includes current default scope set). Retry once.
|
||||
if (accountIndex != null && !refreshAttempted) {
|
||||
try {
|
||||
const { forceRefreshToken } = await import('./token-refresh.js');
|
||||
const newToken = await forceRefreshToken(accountIndex);
|
||||
if (newToken && newToken !== token) {
|
||||
logger.info(
|
||||
{
|
||||
account: accountIndex + 1,
|
||||
status: res.status,
|
||||
cacheKey: writeKey,
|
||||
},
|
||||
`Claude usage API: ${res.status} → refreshed access token, retrying`,
|
||||
);
|
||||
return fetchUsageForToken(newToken, accountIndex, true);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, account: accountIndex + 1 },
|
||||
'Claude usage API: force-refresh failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.warn(
|
||||
{
|
||||
account: accountIndex != null ? accountIndex + 1 : '?',
|
||||
tokenKey: legacyTokenCacheKey(token),
|
||||
cacheKey: writeKey,
|
||||
},
|
||||
'Claude usage API: token expired or invalid (401)',
|
||||
res.status === 401
|
||||
? 'Claude usage API: token expired or invalid (401)'
|
||||
: 'Claude usage API: forbidden — token missing required scope (403)',
|
||||
);
|
||||
return null;
|
||||
if (cached) {
|
||||
cached.lastAttemptAt = Date.now();
|
||||
saveUsageDiskCache();
|
||||
}
|
||||
return cached?.usage ?? null;
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const staleMs = cached ? Date.now() - cached.fetchedAt : 0;
|
||||
@@ -278,7 +316,15 @@ async function fetchUsageForToken(
|
||||
* Uses the current active token from rotation.
|
||||
*/
|
||||
export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||
const token = getCurrentToken() || getConfiguredClaudeTokens()[0];
|
||||
// Prefer the access token in ~/.claude/.credentials.json when present.
|
||||
// The static .env token (CLAUDE_CODE_OAUTH_TOKEN) was issued before the
|
||||
// `user:profile` scope was required for /api/oauth/usage and so always 403s.
|
||||
// The credentials file is the canonical source written by `claude auth
|
||||
// login` and kept fresh by token-refresh.ts — its accessToken carries the
|
||||
// full scope set including user:profile.
|
||||
const credsToken = readCredentialsAccessToken(0);
|
||||
const token =
|
||||
credsToken || getCurrentToken() || getConfiguredClaudeTokens()[0];
|
||||
if (!token) {
|
||||
logger.debug('No Claude OAuth token available for usage check');
|
||||
return null;
|
||||
@@ -435,7 +481,8 @@ export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
|
||||
const allTokens = getAllTokens();
|
||||
logger.debug({ tokenCount: allTokens.length }, 'fetchAllClaudeUsage called');
|
||||
if (allTokens.length === 0) {
|
||||
const token = getConfiguredClaudeTokens()[0];
|
||||
const credsToken = readCredentialsAccessToken(0);
|
||||
const token = credsToken || getConfiguredClaudeTokens()[0];
|
||||
if (!token) return [];
|
||||
const usage = await fetchUsageForToken(token, 0);
|
||||
return [
|
||||
@@ -451,7 +498,14 @@ export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
|
||||
|
||||
const results: ClaudeAccountUsage[] = [];
|
||||
for (const t of allTokens) {
|
||||
const usage = await fetchUsageForToken(t.token, t.index);
|
||||
// Prefer the per-account credentials.json access token when present.
|
||||
// The static .env CLAUDE_CODE_OAUTH_TOKEN(S) values were issued without
|
||||
// the `user:profile` scope and so are rejected by /api/oauth/usage.
|
||||
// The credentials file is rewritten by `claude auth login` and refreshed
|
||||
// by token-refresh.ts, so it always carries the full scope set.
|
||||
const credsToken = readCredentialsAccessToken(t.index);
|
||||
const tokenForFetch = credsToken || t.token;
|
||||
const usage = await fetchUsageForToken(tokenForFetch, t.index);
|
||||
results.push({
|
||||
index: t.index,
|
||||
masked: t.masked,
|
||||
@@ -465,3 +519,103 @@ export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
|
||||
|
||||
// Legacy alias
|
||||
export const fetchClaudeUsageViaCli = fetchClaudeUsage;
|
||||
|
||||
// ── Exhaustion gate ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Threshold above which a Claude account is considered "out of budget" for
|
||||
// the purpose of admitting new turns. A turn is blocked only when *every*
|
||||
// Claude account is either at/over this threshold on either window or
|
||||
// already in a rate-limit cooldown.
|
||||
|
||||
export const CLAUDE_EXHAUSTION_THRESHOLD_PCT = 95;
|
||||
|
||||
export interface UsageExhaustionInfo {
|
||||
exhausted: boolean;
|
||||
/** ISO timestamp of the soonest reset that would relieve exhaustion. */
|
||||
nextResetAt: string | null;
|
||||
}
|
||||
|
||||
/** Normalise the API's utilization field (which can be fractional or percent). */
|
||||
function normaliseUtilization(u: number | undefined): number {
|
||||
if (u == null || !Number.isFinite(u)) return 0;
|
||||
return u >= 0 && u < 1 ? u * 100 : u;
|
||||
}
|
||||
|
||||
function parseResetMs(s: string | undefined | null): number | null {
|
||||
if (!s) return null;
|
||||
const t = Date.parse(s);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether every configured Claude account is either ≥ 95% on a
|
||||
* window or in a rate-limit cooldown, and — when so — the soonest moment at
|
||||
* which any one account is expected to recover.
|
||||
*
|
||||
* Reads the on-disk usage cache (refreshed every minute by the dashboard
|
||||
* renderer); performs no network I/O. Returns `exhausted: false` when there
|
||||
* are no configured tokens, or when usage data is missing for any account
|
||||
* (err on letting work through).
|
||||
*/
|
||||
export function getClaudeUsageExhaustion(): UsageExhaustionInfo {
|
||||
loadUsageDiskCache();
|
||||
const allTokens = getAllTokens();
|
||||
if (allTokens.length === 0) return { exhausted: false, nextResetAt: null };
|
||||
|
||||
let earliestRecoveryMs = Infinity;
|
||||
|
||||
for (const t of allTokens) {
|
||||
if (t.isRateLimited) {
|
||||
// Rate-limit recovery time is not exposed on the token snapshot here;
|
||||
// we treat it as "unknown but eventually". Skip from the min calc.
|
||||
continue;
|
||||
}
|
||||
const credsToken = readCredentialsAccessToken(t.index);
|
||||
const readKeys = getUsageCacheReadKeys(t.token, t.index, credsToken);
|
||||
let entry: UsageCacheEntry | undefined;
|
||||
for (const key of readKeys) {
|
||||
if (usageDiskCache[key]) {
|
||||
entry = usageDiskCache[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!entry) return { exhausted: false, nextResetAt: null };
|
||||
|
||||
const h5 = normaliseUtilization(entry.usage.five_hour?.utilization);
|
||||
const d7 = normaliseUtilization(entry.usage.seven_day?.utilization);
|
||||
if (Math.max(h5, d7) < CLAUDE_EXHAUSTION_THRESHOLD_PCT) {
|
||||
return { exhausted: false, nextResetAt: null };
|
||||
}
|
||||
|
||||
// This account is exhausted. It becomes available again when whichever
|
||||
// window(s) are over the threshold drop back below it. We approximate
|
||||
// that as max(reset of each window currently ≥ threshold).
|
||||
const h5ResetMs =
|
||||
h5 >= CLAUDE_EXHAUSTION_THRESHOLD_PCT
|
||||
? parseResetMs(entry.usage.five_hour?.resets_at)
|
||||
: null;
|
||||
const d7ResetMs =
|
||||
d7 >= CLAUDE_EXHAUSTION_THRESHOLD_PCT
|
||||
? parseResetMs(entry.usage.seven_day?.resets_at)
|
||||
: null;
|
||||
const candidates = [h5ResetMs, d7ResetMs].filter(
|
||||
(v): v is number => v != null,
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
const accountRecovery = Math.max(...candidates);
|
||||
if (accountRecovery < earliestRecoveryMs) {
|
||||
earliestRecoveryMs = accountRecovery;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextResetAt = Number.isFinite(earliestRecoveryMs)
|
||||
? new Date(earliestRecoveryMs).toISOString()
|
||||
: null;
|
||||
return { exhausted: true, nextResetAt };
|
||||
}
|
||||
|
||||
/** Backwards-compat boolean wrapper. */
|
||||
export function isClaudeUsageExhausted(): boolean {
|
||||
return getClaudeUsageExhaustion().exhausted;
|
||||
}
|
||||
|
||||
@@ -31,10 +31,120 @@ export interface CodexUsageRefreshResult {
|
||||
/** Full scan interval — exported so the orchestrator can schedule it. */
|
||||
export const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour
|
||||
|
||||
/**
|
||||
* Threshold above which a Codex account is considered "out of budget" for
|
||||
* the purpose of admitting new turns. A turn is blocked only when *every*
|
||||
* Codex account is either at/over this threshold on either window or
|
||||
* already in a rate-limit cooldown.
|
||||
*/
|
||||
export const CODEX_EXHAUSTION_THRESHOLD_PCT = 95;
|
||||
|
||||
export interface UsageExhaustionInfo {
|
||||
exhausted: boolean;
|
||||
/** ISO timestamp of the soonest reset that would relieve exhaustion. */
|
||||
nextResetAt: string | null;
|
||||
}
|
||||
|
||||
function parseResetMs(s: string | undefined | null): number | null {
|
||||
if (!s) return null;
|
||||
const t = Date.parse(s);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
/** Coerce a string-or-number resetsAt (numeric epoch in seconds) to ISO. */
|
||||
function toIsoMaybe(value: string | number | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value === 'number') {
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
// Already a string — pass through if it parses, else drop.
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether every configured Codex account is either ≥ 95% on a
|
||||
* window or in a rate-limit cooldown, and — when so — the soonest moment
|
||||
* at which any one account is expected to recover.
|
||||
*
|
||||
* Reads the in-process rotation snapshot; performs no I/O. Returns
|
||||
* `exhausted: false` when there are no configured accounts, or when usage
|
||||
* data is missing for any account (err on letting work through).
|
||||
*/
|
||||
export function getCodexUsageExhaustion(): UsageExhaustionInfo {
|
||||
const accounts = getAllCodexAccounts();
|
||||
if (accounts.length === 0) return { exhausted: false, nextResetAt: null };
|
||||
|
||||
let earliestRecoveryMs = Infinity;
|
||||
|
||||
for (const a of accounts) {
|
||||
if (a.isRateLimited) continue; // counts as exhausted, recovery unknown here
|
||||
const h5 = a.cachedUsagePct;
|
||||
const d7 = a.cachedUsageD7Pct;
|
||||
// -1 / undefined means "unknown" → treat as headroom
|
||||
if (h5 == null || h5 < 0 || d7 == null || d7 < 0) {
|
||||
return { exhausted: false, nextResetAt: null };
|
||||
}
|
||||
if (Math.max(h5, d7) < CODEX_EXHAUSTION_THRESHOLD_PCT) {
|
||||
return { exhausted: false, nextResetAt: null };
|
||||
}
|
||||
|
||||
const h5ResetMs =
|
||||
h5 >= CODEX_EXHAUSTION_THRESHOLD_PCT ? parseResetMs(a.resetAt) : null;
|
||||
const d7ResetMs =
|
||||
d7 >= CODEX_EXHAUSTION_THRESHOLD_PCT ? parseResetMs(a.resetD7At) : null;
|
||||
const candidates = [h5ResetMs, d7ResetMs].filter(
|
||||
(v): v is number => v != null,
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
const accountRecovery = Math.max(...candidates);
|
||||
if (accountRecovery < earliestRecoveryMs) {
|
||||
earliestRecoveryMs = accountRecovery;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextResetAt = Number.isFinite(earliestRecoveryMs)
|
||||
? new Date(earliestRecoveryMs).toISOString()
|
||||
: null;
|
||||
return { exhausted: true, nextResetAt };
|
||||
}
|
||||
|
||||
/** Backwards-compat boolean wrapper. */
|
||||
export function isCodexUsageExhausted(): boolean {
|
||||
return getCodexUsageExhaustion().exhausted;
|
||||
}
|
||||
|
||||
function getFnmCodexBinDirs(): string[] {
|
||||
const fnmRoot = path.join(os.homedir(), '.local', 'share', 'fnm');
|
||||
const dirs: string[] = [];
|
||||
// Prefer the alias `default` first if it exists.
|
||||
const defaultBin = path.join(fnmRoot, 'aliases', 'default', 'bin');
|
||||
if (fs.existsSync(defaultBin)) dirs.push(defaultBin);
|
||||
// Then fall back to scanning every installed node version.
|
||||
const versionsRoot = path.join(fnmRoot, 'node-versions');
|
||||
try {
|
||||
if (fs.existsSync(versionsRoot)) {
|
||||
for (const entry of fs.readdirSync(versionsRoot)) {
|
||||
const bin = path.join(versionsRoot, entry, 'installation', 'bin');
|
||||
if (fs.existsSync(bin)) dirs.push(bin);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function getPreferredCodexPathEntries(): string[] {
|
||||
const entries = [
|
||||
path.dirname(process.execPath),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
// fnm-managed node installs (where `npm i -g @openai/codex` actually
|
||||
// lands when the host uses fnm). Without these, ejclaw running under
|
||||
// bun/systemd cannot find the `codex` binary even though the user has
|
||||
// it installed via fnm's default node.
|
||||
...getFnmCodexBinDirs(),
|
||||
];
|
||||
if (process.versions.bun || path.basename(process.execPath) === 'bun') {
|
||||
entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin'));
|
||||
@@ -42,6 +152,18 @@ function getPreferredCodexPathEntries(): string[] {
|
||||
return [...new Set(entries)];
|
||||
}
|
||||
|
||||
function findCodexBinary(): string {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), '.npm-global', 'bin', 'codex'),
|
||||
...getFnmCodexBinDirs().map((dir) => path.join(dir, 'codex')),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
// Last resort: rely on PATH (we extend it via getPreferredCodexPathEntries).
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
function getCodexHomeForAccount(accountIndex?: number): string | null {
|
||||
const authPath = getCodexAuthPath(accountIndex);
|
||||
if (!authPath || !fs.existsSync(authPath)) return null;
|
||||
@@ -51,8 +173,7 @@ function getCodexHomeForAccount(accountIndex?: number): string | null {
|
||||
export async function fetchCodexUsage(
|
||||
codexHomeOverride?: string,
|
||||
): Promise<CodexRateLimit[] | null> {
|
||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
||||
const codexBin = findCodexBinary();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
@@ -197,20 +318,19 @@ export function applyCodexUsageToAccount(
|
||||
|
||||
const pct = Math.round(effective.primary.usedPercent);
|
||||
const d7Pct = Math.round(effective.secondary.usedPercent);
|
||||
const resetStr = effective.primary.resetsAt
|
||||
? formatResetRemaining(effective.primary.resetsAt)
|
||||
: undefined;
|
||||
const resetD7Str = effective.secondary.resetsAt
|
||||
? formatResetRemaining(effective.secondary.resetsAt)
|
||||
: undefined;
|
||||
updateCodexAccountUsage(pct, resetStr, accountIndex, d7Pct, resetD7Str);
|
||||
// Store raw ISO timestamps (not pre-formatted strings) so the exhaustion
|
||||
// gate can compute "minutes until reset" later. The dashboard render path
|
||||
// formats these at display time via `formatResetRemaining`.
|
||||
const resetIso = toIsoMaybe(effective.primary.resetsAt);
|
||||
const resetD7Iso = toIsoMaybe(effective.secondary.resetsAt);
|
||||
updateCodexAccountUsage(pct, resetIso, accountIndex, d7Pct, resetD7Iso);
|
||||
logger.info(
|
||||
{
|
||||
account: accountIndex + 1,
|
||||
bucket: effective.limitId,
|
||||
h5: pct,
|
||||
d7: d7Pct,
|
||||
reset: resetStr,
|
||||
reset: resetIso,
|
||||
},
|
||||
`Codex account #${accountIndex + 1} usage: 5h=${pct}% 7d=${d7Pct}%`,
|
||||
);
|
||||
@@ -233,9 +353,9 @@ export function buildCodexUsageRowsFromState(): UsageRow[] {
|
||||
return {
|
||||
name: label,
|
||||
h5pct: acct.cachedUsagePct != null ? acct.cachedUsagePct : -1,
|
||||
h5reset: acct.resetAt || '',
|
||||
h5reset: acct.resetAt ? formatResetRemaining(acct.resetAt) : '',
|
||||
d7pct: acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1,
|
||||
d7reset: acct.resetD7At || '',
|
||||
d7reset: acct.resetD7At ? formatResetRemaining(acct.resetD7At) : '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export function loadConfig(): AppConfig {
|
||||
status: {
|
||||
channelId: readText('STATUS_CHANNEL_ID') ?? '',
|
||||
updateInterval: 10000,
|
||||
usageUpdateInterval: 300000,
|
||||
usageUpdateInterval: 60000,
|
||||
showRooms: readBooleanUnlessFalse('STATUS_SHOW_ROOMS', true),
|
||||
showRoomDetails: readBooleanUnlessFalse('STATUS_SHOW_ROOM_DETAILS', true),
|
||||
usageDashboardEnabled: readText('USAGE_DASHBOARD') === 'true',
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('listUnexpectedDataStateFiles', () => {
|
||||
fs.writeFileSync(path.join(dataDir, 'codex-warmup-state.json'), '{}');
|
||||
fs.writeFileSync(path.join(dataDir, 'claude-usage-cache.json'), '{}');
|
||||
fs.writeFileSync(path.join(dataDir, 'restart-context.abc.json'), '{}');
|
||||
fs.writeFileSync(path.join(dataDir, 'auto-paired-mode-state.json'), '{}');
|
||||
|
||||
expect(listUnexpectedDataStateFiles(dataDir)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const ALLOWED_DATA_JSON_PATTERNS = [
|
||||
/^codex-warmup-state\.json$/,
|
||||
/^claude-usage-cache\.json$/,
|
||||
/^restart-context\..+\.json$/,
|
||||
/^auto-paired-mode-state\.json$/,
|
||||
];
|
||||
|
||||
function isAllowedDataStateJson(filename: string): boolean {
|
||||
|
||||
@@ -2725,7 +2725,10 @@ describe('room assignment writes', () => {
|
||||
modeSource: 'inferred',
|
||||
name: 'Legacy SQL Room',
|
||||
folder: 'legacy-sql-room',
|
||||
ownerAgentType: 'codex',
|
||||
// Owner inference picks OWNER_AGENT_TYPE (env-driven, default codex but
|
||||
// currently claude-code in this deployment) when both agent types are
|
||||
// registered. See inferOwnerAgentTypeFromRegisteredAgentTypes.
|
||||
ownerAgentType: OWNER_AGENT_TYPE,
|
||||
});
|
||||
expect(getEffectiveRoomMode('dc:legacy-sql')).toBe('tribunal');
|
||||
expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal');
|
||||
@@ -3341,7 +3344,8 @@ describe('paired room registration', () => {
|
||||
name: 'Room Settings Test',
|
||||
folder: 'room-settings-test',
|
||||
trigger: '@Codex',
|
||||
ownerAgentType: 'codex',
|
||||
// Both agent types registered → owner inference picks OWNER_AGENT_TYPE.
|
||||
ownerAgentType: OWNER_AGENT_TYPE,
|
||||
});
|
||||
|
||||
setExplicitRoomMode('dc:room-settings', 'single');
|
||||
@@ -3353,7 +3357,7 @@ describe('paired room registration', () => {
|
||||
name: 'Room Settings Test',
|
||||
folder: 'room-settings-test',
|
||||
trigger: '@Codex',
|
||||
ownerAgentType: 'codex',
|
||||
ownerAgentType: OWNER_AGENT_TYPE,
|
||||
});
|
||||
|
||||
updateRegisteredGroupName('dc:room-settings', 'Room Settings Renamed');
|
||||
@@ -3365,7 +3369,7 @@ describe('paired room registration', () => {
|
||||
name: 'Room Settings Renamed',
|
||||
folder: 'room-settings-test',
|
||||
trigger: '@Codex',
|
||||
ownerAgentType: 'codex',
|
||||
ownerAgentType: OWNER_AGENT_TYPE,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
enforceMemoryBounds,
|
||||
expireStaleMemories,
|
||||
getAllChats,
|
||||
getEarliestUnansweredHumanSeq,
|
||||
getLastHumanMessageContent,
|
||||
getLastHumanMessageSender,
|
||||
getLastHumanMessageTimestamp,
|
||||
@@ -22,6 +23,7 @@ export {
|
||||
getOpenWorkItem,
|
||||
getOpenWorkItemForChat,
|
||||
getRecentChatMessages,
|
||||
getRecentDeliveredOwnerWorkItemsForChat,
|
||||
hasRecentRestartAnnouncement,
|
||||
markWorkItemDelivered,
|
||||
markWorkItemDeliveryRetry,
|
||||
|
||||
@@ -332,6 +332,38 @@ export function getRecentChatMessagesFromDatabase(
|
||||
return rows.map(normalizeMessageRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the seq of the earliest unanswered human message in this chat —
|
||||
* i.e. the first user message that came after the most recent bot reply.
|
||||
* Returns null if no such message exists (the user is "caught up").
|
||||
*
|
||||
* Used by restart-recovery to rewind the agent cursor before re-running an
|
||||
* interrupted turn from the message that initiated it.
|
||||
*/
|
||||
export function getEarliestUnansweredHumanSeqFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
): number | null {
|
||||
const lastBot = database
|
||||
.prepare(
|
||||
`SELECT MAX(seq) AS seq FROM messages
|
||||
WHERE chat_jid = ? AND is_bot_message = 1`,
|
||||
)
|
||||
.get(chatJid) as { seq: number | null } | undefined;
|
||||
const lastBotSeq = lastBot?.seq ?? 0;
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT MIN(seq) AS seq FROM messages
|
||||
WHERE chat_jid = ?
|
||||
AND seq > ?
|
||||
AND is_bot_message = 0
|
||||
AND is_from_me = 0
|
||||
AND content != '' AND content IS NOT NULL`,
|
||||
)
|
||||
.get(chatJid, lastBotSeq) as { seq: number | null } | undefined;
|
||||
return row?.seq ?? null;
|
||||
}
|
||||
|
||||
export function getLastHumanMessageTimestampFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import { parseVisibleVerdict } from '../paired-verdict.js';
|
||||
import {
|
||||
parseReviewerVisibleVerdict,
|
||||
parseVisibleVerdict,
|
||||
} from '../paired-verdict.js';
|
||||
import { PairedRoomRole, PairedTurnOutput } from '../types.js';
|
||||
|
||||
const MAX_TURN_OUTPUT_CHARS = 50_000;
|
||||
@@ -38,7 +41,9 @@ export function insertPairedTurnOutputInDatabase(
|
||||
turnNumber,
|
||||
role,
|
||||
outputText.slice(0, MAX_TURN_OUTPUT_CHARS),
|
||||
parseVisibleVerdict(outputText),
|
||||
role === 'reviewer'
|
||||
? parseReviewerVisibleVerdict(outputText)
|
||||
: parseVisibleVerdict(outputText),
|
||||
createdAt ?? new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
type ChatInfo,
|
||||
getAllChatsFromDatabase,
|
||||
getEarliestUnansweredHumanSeqFromDatabase,
|
||||
getLastHumanMessageContentFromDatabase,
|
||||
getLastHumanMessageSenderFromDatabase,
|
||||
getLastHumanMessageTimestampFromDatabase,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
createProducedWorkItemInDatabase,
|
||||
getOpenWorkItemForChatFromDatabase,
|
||||
getOpenWorkItemFromDatabase,
|
||||
getRecentDeliveredOwnerWorkItemsForChatFromDatabase,
|
||||
markWorkItemDeliveredInDatabase,
|
||||
markWorkItemDeliveryRetryInDatabase,
|
||||
} from './work-items.js';
|
||||
@@ -223,6 +225,10 @@ export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
||||
return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid);
|
||||
}
|
||||
|
||||
export function getEarliestUnansweredHumanSeq(chatJid: string): number | null {
|
||||
return getEarliestUnansweredHumanSeqFromDatabase(requireDatabase(), chatJid);
|
||||
}
|
||||
|
||||
export function getLastHumanMessageSender(chatJid: string): string | null {
|
||||
return getLastHumanMessageSenderFromDatabase(requireDatabase(), chatJid);
|
||||
}
|
||||
@@ -266,6 +272,17 @@ export function getOpenWorkItemForChat(
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecentDeliveredOwnerWorkItemsForChat(
|
||||
chatJid: string,
|
||||
limit: number,
|
||||
): WorkItem[] {
|
||||
return getRecentDeliveredOwnerWorkItemsForChatFromDatabase(
|
||||
requireDatabase(),
|
||||
chatJid,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
export function createProducedWorkItem(
|
||||
input: CreateProducedWorkItemInput,
|
||||
): WorkItem {
|
||||
|
||||
@@ -199,6 +199,34 @@ export function getOpenWorkItemFromDatabase(
|
||||
return row ? hydrateWorkItemRow(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent delivered owner-side work items for a chat, newest last.
|
||||
* Used by single-mode prompt building to surface the bot's own prior
|
||||
* answers (which only live in work_items.result_payload) so a new turn
|
||||
* can reference what it previously said.
|
||||
*/
|
||||
export function getRecentDeliveredOwnerWorkItemsForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
limit: number,
|
||||
): WorkItem[] {
|
||||
if (limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT *
|
||||
FROM work_items
|
||||
WHERE chat_jid = ?
|
||||
AND status = 'delivered'
|
||||
AND (delivery_role = 'owner' OR delivery_role IS NULL)
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(chatJid, limit) as StoredWorkItemRow[];
|
||||
return rows.map(hydrateWorkItemRow).reverse();
|
||||
}
|
||||
|
||||
export function getOpenWorkItemForChatFromDatabase(
|
||||
database: Database,
|
||||
chatJid: string,
|
||||
|
||||
54
src/index.ts
54
src/index.ts
@@ -21,6 +21,7 @@ import { writeGroupsSnapshot } from './agent-runner.js';
|
||||
import {
|
||||
type AssignRoomInput,
|
||||
getAllTasks,
|
||||
getEarliestUnansweredHumanSeq,
|
||||
hasRecentRestartAnnouncement,
|
||||
initDatabase,
|
||||
storeChatMetadata,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
import { createMessageRuntime } from './message-runtime.js';
|
||||
import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
|
||||
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||
import { startUsagePrimer } from './usage-primer.js';
|
||||
import { startWebDashboardServer } from './web-dashboard-server.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -270,14 +272,29 @@ async function main(): Promise<void> {
|
||||
status: 'processing' | 'waiting';
|
||||
} => status.status !== 'inactive',
|
||||
)
|
||||
.map((status) => ({
|
||||
.map((status) => {
|
||||
// Capture the seq of the earliest unanswered human message for this
|
||||
// chat so the next startup can rewind the agent cursor and re-run
|
||||
// the interrupted turn automatically.
|
||||
let rewindToSeq: number | null = null;
|
||||
try {
|
||||
rewindToSeq = getEarliestUnansweredHumanSeq(status.jid);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, chatJid: status.jid },
|
||||
'Failed to capture rewind seq for shutdown snapshot',
|
||||
);
|
||||
}
|
||||
return {
|
||||
chatJid: status.jid,
|
||||
groupName: roomBindings[status.jid]?.name || status.jid,
|
||||
status: status.status,
|
||||
elapsedMs: status.elapsedMs,
|
||||
pendingMessages: status.pendingMessages,
|
||||
pendingTasks: status.pendingTasks,
|
||||
}));
|
||||
rewindToSeq,
|
||||
};
|
||||
});
|
||||
const writtenPaths = writeShutdownRestartContext(
|
||||
roomBindings,
|
||||
interruptedGroups,
|
||||
@@ -488,6 +505,37 @@ async function main(): Promise<void> {
|
||||
restartContext,
|
||||
roomBindings,
|
||||
)) {
|
||||
// Auto-resume: rewind the agent cursor to just before the earliest
|
||||
// unanswered human message so the missed-message processor picks it up
|
||||
// again. Claude/Codex sessions are persisted by group folder, so the
|
||||
// agent will resume mid-turn rather than restarting from scratch.
|
||||
if (candidate.rewindToSeq != null && candidate.rewindToSeq > 0) {
|
||||
const cursors = runtimeState.getLastAgentTimestamps();
|
||||
const previousCursor = cursors[candidate.chatJid];
|
||||
const targetCursor = String(candidate.rewindToSeq - 1);
|
||||
// Only rewind if the current cursor is past the target — never advance.
|
||||
const previousNum = previousCursor
|
||||
? Number.parseInt(previousCursor, 10)
|
||||
: 0;
|
||||
if (
|
||||
Number.isFinite(previousNum) &&
|
||||
previousNum >= candidate.rewindToSeq
|
||||
) {
|
||||
cursors[candidate.chatJid] = targetCursor;
|
||||
runtimeState.saveState();
|
||||
logger.info(
|
||||
{
|
||||
chatJid: candidate.chatJid,
|
||||
groupFolder: candidate.groupFolder,
|
||||
previousCursor,
|
||||
rewoundTo: targetCursor,
|
||||
rewindToSeq: candidate.rewindToSeq,
|
||||
},
|
||||
'Rewound agent cursor for interrupted-turn auto-resume',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
queue.enqueueMessageCheck(
|
||||
candidate.chatJid,
|
||||
resolveGroupIpcPath(candidate.groupFolder),
|
||||
@@ -499,6 +547,7 @@ async function main(): Promise<void> {
|
||||
status: candidate.status,
|
||||
pendingMessages: candidate.pendingMessages,
|
||||
pendingTasks: candidate.pendingTasks,
|
||||
rewindToSeq: candidate.rewindToSeq,
|
||||
},
|
||||
'Queued interrupted group for restart recovery',
|
||||
);
|
||||
@@ -522,6 +571,7 @@ async function main(): Promise<void> {
|
||||
purgeOnStart: true,
|
||||
});
|
||||
webDashboardServer = startWebDashboardServer(WEB_DASHBOARD);
|
||||
startUsagePrimer();
|
||||
|
||||
leaseRecoveryTimer = setInterval(() => {
|
||||
const failover = getGlobalFailoverInfo();
|
||||
|
||||
@@ -200,6 +200,12 @@ export async function executeMessageAgentAttemptLifecycle(args: {
|
||||
|
||||
const freshAttempt = await runTrackedAttempt('claude');
|
||||
if (!isRetryableClaudeSessionFailure(freshAttempt)) {
|
||||
// The session-poisoning that triggered this recovery was handled by the
|
||||
// clearStoredSession() above, and the fresh attempt has already
|
||||
// persisted its own session_id via onPersistSession. Reset the outer
|
||||
// flag so that finalizePrimaryAttempt won't wipe the freshly persisted
|
||||
// session based on the previous attempt's stale failure state.
|
||||
resetSessionRequested = freshAttempt.resetSessionRequested === true;
|
||||
return { attempt: freshAttempt, resolved: null };
|
||||
}
|
||||
|
||||
@@ -251,6 +257,11 @@ export async function executeMessageAgentAttemptLifecycle(args: {
|
||||
|
||||
const freshAttempt = await runTrackedAttempt('codex');
|
||||
if (!isRetryableCodexSessionFailure(freshAttempt)) {
|
||||
// See the matching Claude recovery branch above: clear the outer
|
||||
// reset-session flag so finalizePrimaryAttempt doesn't wipe the
|
||||
// freshly persisted Codex session based on the previous attempt's
|
||||
// stale failure state.
|
||||
resetSessionRequested = freshAttempt.resetSessionRequested === true;
|
||||
return { attempt: freshAttempt, resolved: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -355,7 +355,10 @@ export function createPairedExecutionLifecycle(args: {
|
||||
requiresVisibleVerdict &&
|
||||
(!pairedFinalOutput || pairedFinalOutput.length === 0);
|
||||
if (missingVisibleVerdict) {
|
||||
pairedExecutionSummary = missingVisibleVerdictSummary;
|
||||
const previousSummary = pairedExecutionSummary?.trim();
|
||||
pairedExecutionSummary = previousSummary
|
||||
? `${previousSummary}\n\n${missingVisibleVerdictSummary}`
|
||||
: missingVisibleVerdictSummary;
|
||||
log.warn(
|
||||
{
|
||||
pairedTaskId: pairedExecutionContext?.task.id ?? null,
|
||||
|
||||
@@ -1190,7 +1190,9 @@ describe('runAgentForGroup room memory', () => {
|
||||
taskId: 'paired-task-review-no-verdict',
|
||||
role: 'reviewer',
|
||||
status: 'failed',
|
||||
summary: 'Execution completed without a visible terminal verdict.',
|
||||
summary: expect.stringContaining(
|
||||
'Execution completed without a visible terminal verdict.',
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(db.failPairedTurn).toHaveBeenCalledWith({
|
||||
@@ -1202,7 +1204,9 @@ describe('runAgentForGroup room memory', () => {
|
||||
intentKind: 'reviewer-turn',
|
||||
role: 'reviewer',
|
||||
},
|
||||
error: 'Execution completed without a visible terminal verdict.',
|
||||
error: expect.stringContaining(
|
||||
'Execution completed without a visible terminal verdict.',
|
||||
),
|
||||
});
|
||||
expect(db.completePairedTurn).not.toHaveBeenCalled();
|
||||
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
|
||||
@@ -3459,6 +3463,14 @@ describe('runAgentForGroup Claude rotation', () => {
|
||||
expect(result).toBe('success');
|
||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
|
||||
expect(deps.clearSession).toHaveBeenCalledWith('test-claude');
|
||||
// The session must be cleared exactly once (before the fresh retry).
|
||||
// The fresh retry's newSessionId must end up persisted so the NEXT
|
||||
// user turn can resume that session.
|
||||
expect(deps.clearSession).toHaveBeenCalledTimes(1);
|
||||
expect(deps.persistSession).toHaveBeenCalledWith(
|
||||
'test-claude',
|
||||
'fresh-session-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a poisoned Codex session id before retrying a fresh session after remote compaction failure', async () => {
|
||||
@@ -3487,9 +3499,13 @@ describe('runAgentForGroup Claude rotation', () => {
|
||||
vi.mocked(sessionRecovery.shouldRetryFreshCodexSessionOnAgentFailure)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false);
|
||||
vi.mocked(sessionRecovery.shouldResetCodexSessionOnAgentFailure)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false);
|
||||
// The first attempt throws synchronously, so streaming evaluation never
|
||||
// runs on its (non-existent) output — only the fresh retry's success
|
||||
// output reaches shouldResetCodexSessionOnAgentFailure, and that output
|
||||
// does not match any reset pattern.
|
||||
vi.mocked(
|
||||
sessionRecovery.shouldResetCodexSessionOnAgentFailure,
|
||||
).mockReturnValue(false);
|
||||
|
||||
vi.mocked(agentRunner.runAgentProcess)
|
||||
.mockImplementationOnce(async (_group, input) => {
|
||||
@@ -3523,6 +3539,13 @@ describe('runAgentForGroup Claude rotation', () => {
|
||||
expect(result).toBe('success');
|
||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
|
||||
expect(deps.clearSession).toHaveBeenCalledWith('test-codex');
|
||||
// Same invariant as Claude: clearSession runs exactly once and the
|
||||
// fresh retry's newSessionId is persisted for the next turn.
|
||||
expect(deps.clearSession).toHaveBeenCalledTimes(1);
|
||||
expect(deps.persistSession).toHaveBeenCalledWith(
|
||||
'test-codex',
|
||||
'fresh-codex-session-id',
|
||||
);
|
||||
});
|
||||
|
||||
it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => {
|
||||
|
||||
@@ -9,9 +9,14 @@ vi.mock('./session-commands.js', () => ({
|
||||
handleSessionCommand: handleSessionCommandMock,
|
||||
}));
|
||||
|
||||
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
||||
import {
|
||||
exhaustionChecks,
|
||||
handleQueuedRunGates,
|
||||
} from './message-runtime-gating.js';
|
||||
|
||||
describe('message-runtime-gating', () => {
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
const baseArgs = {
|
||||
chatJid: 'room-1',
|
||||
group: {
|
||||
@@ -20,17 +25,21 @@ describe('message-runtime-gating', () => {
|
||||
isMain: false,
|
||||
trigger: '코덱스',
|
||||
added_at: new Date().toISOString(),
|
||||
agentType: 'claude-code' as const,
|
||||
} satisfies RegisteredGroup,
|
||||
runId: 'run-1',
|
||||
missedMessages: [],
|
||||
triggerPattern: /^코덱스/,
|
||||
timezone: 'Asia/Seoul',
|
||||
hasImplicitContinuationWindow: () => false,
|
||||
sessionCommandDeps: {} as never,
|
||||
sessionCommandDeps: { sendMessage } as never,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
handleSessionCommandMock.mockReset();
|
||||
sendMessage.mockReset();
|
||||
exhaustionChecks.claude = () => ({ exhausted: false, nextResetAt: null });
|
||||
exhaustionChecks.codex = () => ({ exhausted: false, nextResetAt: null });
|
||||
});
|
||||
|
||||
it('returns session-command results directly when a command is handled', async () => {
|
||||
@@ -42,13 +51,79 @@ describe('message-runtime-gating', () => {
|
||||
const result = await handleQueuedRunGates(baseArgs);
|
||||
|
||||
expect(result).toEqual({ handled: true, success: false });
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through when no session command is handled', async () => {
|
||||
it('falls through when no session command is handled and accounts have headroom', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
|
||||
const result = await handleQueuedRunGates(baseArgs);
|
||||
|
||||
expect(result).toEqual({ handled: false });
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks the turn and notifies the channel when Claude is exhausted', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
const resetAt = new Date(Date.now() + 90 * 60_000).toISOString(); // ~1h30m
|
||||
exhaustionChecks.claude = () => ({
|
||||
exhausted: true,
|
||||
nextResetAt: resetAt,
|
||||
});
|
||||
|
||||
const result = await handleQueuedRunGates(baseArgs);
|
||||
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
const text = sendMessage.mock.calls[0][0];
|
||||
expect(text).toMatch(/토큰 부족으로 종료됨/);
|
||||
expect(text).toMatch(/Claude/);
|
||||
expect(text).toMatch(/초기화 예정/);
|
||||
expect(text).toMatch(/1시간/);
|
||||
});
|
||||
|
||||
it('blocks the turn and notifies the channel when Codex is exhausted', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
const resetAt = new Date(Date.now() + 25 * 60_000).toISOString(); // ~25m
|
||||
exhaustionChecks.codex = () => ({
|
||||
exhausted: true,
|
||||
nextResetAt: resetAt,
|
||||
});
|
||||
|
||||
const result = await handleQueuedRunGates({
|
||||
...baseArgs,
|
||||
group: { ...baseArgs.group, agentType: 'codex' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
const text = sendMessage.mock.calls[0][0];
|
||||
expect(text).toMatch(/Codex/);
|
||||
expect(text).toMatch(/약 \d+분 뒤 초기화 예정/);
|
||||
});
|
||||
|
||||
it('omits countdown when no reset time is known', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
exhaustionChecks.claude = () => ({ exhausted: true, nextResetAt: null });
|
||||
|
||||
const result = await handleQueuedRunGates(baseArgs);
|
||||
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
const text = sendMessage.mock.calls[0][0];
|
||||
expect(text).toMatch(/토큰 부족으로 종료됨/);
|
||||
expect(text).not.toMatch(/초기화 예정/);
|
||||
});
|
||||
|
||||
it('does not cross-block: Codex exhaustion does not stop a Claude room', async () => {
|
||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||
exhaustionChecks.codex = () => ({
|
||||
exhausted: true,
|
||||
nextResetAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
|
||||
const result = await handleQueuedRunGates(baseArgs);
|
||||
|
||||
expect(result).toEqual({ handled: false });
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,80 @@
|
||||
import {
|
||||
CLAUDE_EXHAUSTION_THRESHOLD_PCT,
|
||||
getClaudeUsageExhaustion,
|
||||
} from './claude-usage.js';
|
||||
import {
|
||||
CODEX_EXHAUSTION_THRESHOLD_PCT,
|
||||
getCodexUsageExhaustion,
|
||||
} from './codex-usage-collector.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
handleSessionCommand,
|
||||
type SessionCommandDeps,
|
||||
} from './session-commands.js';
|
||||
import type { NewMessage, RegisteredGroup } from './types.js';
|
||||
import type { AgentType, NewMessage, RegisteredGroup } from './types.js';
|
||||
|
||||
/**
|
||||
* Test seam — overridable from unit tests so we don't need a real disk
|
||||
* cache or rotation state to exercise the exhaustion gate.
|
||||
*/
|
||||
export const exhaustionChecks = {
|
||||
claude: getClaudeUsageExhaustion,
|
||||
codex: getCodexUsageExhaustion,
|
||||
};
|
||||
|
||||
/** Render an ISO timestamp as "약 N분 뒤" / "약 Nh Nm 뒤" relative to now. */
|
||||
function formatResetCountdown(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const target = Date.parse(iso);
|
||||
if (!Number.isFinite(target)) return null;
|
||||
const diffMs = target - Date.now();
|
||||
if (diffMs <= 0) return '곧';
|
||||
const totalMinutes = Math.ceil(diffMs / 60_000);
|
||||
if (totalMinutes < 60) return `약 ${totalMinutes}분 뒤`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours < 24) {
|
||||
return minutes > 0
|
||||
? `약 ${hours}시간 ${minutes}분 뒤`
|
||||
: `약 ${hours}시간 뒤`;
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
const remH = hours % 24;
|
||||
return remH > 0 ? `약 ${days}일 ${remH}시간 뒤` : `약 ${days}일 뒤`;
|
||||
}
|
||||
|
||||
function exhaustionMessageFor(
|
||||
agentType: AgentType,
|
||||
nextResetAt: string | null,
|
||||
): string {
|
||||
const label = agentType === 'codex' ? 'Codex' : 'Claude';
|
||||
const pct =
|
||||
agentType === 'codex'
|
||||
? CODEX_EXHAUSTION_THRESHOLD_PCT
|
||||
: CLAUDE_EXHAUSTION_THRESHOLD_PCT;
|
||||
const countdown = formatResetCountdown(nextResetAt);
|
||||
const head = `토큰 부족으로 종료됨 (${label} 모든 계정 사용량 ${pct}% 초과)`;
|
||||
return countdown ? `${head} — ${countdown} 초기화 예정` : head;
|
||||
}
|
||||
|
||||
// ── Usage-window alignment gap ──
|
||||
// Bot primer fires at 08/13/18/23 KST to anchor 5h windows. The 04:00–08:00
|
||||
// KST gap before the day's first primer must be free of inference calls,
|
||||
// otherwise an off-cycle window starts and the daily reset alignment drifts.
|
||||
// During the gap, send an auto-reply instead of running the model.
|
||||
// Urgent bypass: any message containing an @-mention (bot's trigger pattern)
|
||||
// is processed anyway — user explicitly tagged the bot, accept the drift.
|
||||
const GAP_START_HOUR_KST = 4;
|
||||
const GAP_END_HOUR_KST = 8;
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
export function isInUsageAlignmentGap(nowMs: number = Date.now()): boolean {
|
||||
const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours();
|
||||
return kstHour >= GAP_START_HOUR_KST && kstHour < GAP_END_HOUR_KST;
|
||||
}
|
||||
|
||||
const GAP_AUTO_REPLY =
|
||||
'사용량 정렬 시간(04~08 KST)입니다. 08시 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.';
|
||||
|
||||
export async function handleQueuedRunGates(args: {
|
||||
chatJid: string;
|
||||
@@ -31,5 +102,57 @@ export async function handleQueuedRunGates(args: {
|
||||
return cmdResult;
|
||||
}
|
||||
|
||||
// ── Usage-window alignment gap gate ──
|
||||
if (isInUsageAlignmentGap()) {
|
||||
const hasUrgentMention = args.missedMessages.some((m) =>
|
||||
args.triggerPattern.test(m.content),
|
||||
);
|
||||
if (!hasUrgentMention) {
|
||||
logger.info(
|
||||
{
|
||||
chatJid: args.chatJid,
|
||||
groupName: args.group.name,
|
||||
runId: args.runId,
|
||||
},
|
||||
'Blocking new turn — usage alignment gap (04-08 KST)',
|
||||
);
|
||||
try {
|
||||
await args.sessionCommandDeps.sendMessage(GAP_AUTO_REPLY);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to send alignment gap notice');
|
||||
}
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Usage-exhaustion gate ──
|
||||
// Block new turns when the agent provider that would handle this room has
|
||||
// every account at ≥95% on either window (or rate-limited). In-flight
|
||||
// runs are not affected; this only stops *new* turns from starting.
|
||||
const agentType: AgentType = args.group.agentType ?? 'claude-code';
|
||||
const info =
|
||||
agentType === 'codex'
|
||||
? exhaustionChecks.codex()
|
||||
: exhaustionChecks.claude();
|
||||
if (info.exhausted) {
|
||||
const text = exhaustionMessageFor(agentType, info.nextResetAt);
|
||||
logger.warn(
|
||||
{
|
||||
chatJid: args.chatJid,
|
||||
groupName: args.group.name,
|
||||
agentType,
|
||||
runId: args.runId,
|
||||
nextResetAt: info.nextResetAt,
|
||||
},
|
||||
'Blocking new turn — all accounts exhausted',
|
||||
);
|
||||
try {
|
||||
await args.sessionCommandDeps.sendMessage(text);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to send exhaustion notice');
|
||||
}
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getPairedTurnOutputs, getRecentChatMessages } from './db.js';
|
||||
import {
|
||||
getPairedTurnOutputs,
|
||||
getRecentChatMessages,
|
||||
getRecentDeliveredOwnerWorkItemsForChat,
|
||||
} from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
buildArbiterPromptForTask,
|
||||
@@ -270,10 +274,31 @@ export async function runQueuedGroupTurn(args: {
|
||||
turnOutputs,
|
||||
});
|
||||
} else {
|
||||
prompt = args.formatMessages(
|
||||
// Single-mode (or paired-not-yet-activated) fallback.
|
||||
// The messages table only stores human messages, so the bot's own prior
|
||||
// answers (which live in work_items.result_payload) are otherwise invisible
|
||||
// to the next turn. Prepend the most recent delivered owner answers so the
|
||||
// model can reference what it previously said (e.g. "translate your last
|
||||
// answer to Korean").
|
||||
const recentBotAnswers = getRecentDeliveredOwnerWorkItemsForChat(
|
||||
chatJid,
|
||||
3,
|
||||
);
|
||||
const formattedNew = args.formatMessages(
|
||||
args.labelPairedSenders(chatJid, missedMessages),
|
||||
args.timezone,
|
||||
);
|
||||
if (recentBotAnswers.length === 0) {
|
||||
prompt = formattedNew;
|
||||
} else {
|
||||
const priorBlock = recentBotAnswers
|
||||
.map((item, idx) => {
|
||||
const label = `Prior bot answer ${idx + 1}/${recentBotAnswers.length} (delivered ${item.delivered_at ?? item.updated_at})`;
|
||||
return `${label}:\n${item.result_payload}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
prompt = `[Recent prior answers from you in this channel — for context only, do not re-send]\n${priorBlock}\n\n[New messages to respond to]\n${formattedNew}`;
|
||||
}
|
||||
}
|
||||
|
||||
const startSeq = missedMessages[0].seq ?? null;
|
||||
|
||||
@@ -56,7 +56,27 @@ import {
|
||||
} from './types.js';
|
||||
import { createScopedLogger, logger } from './logger.js';
|
||||
import { hasReviewerLease } from './service-routing.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import type { WorkItem } from './db/work-items.js';
|
||||
|
||||
/**
|
||||
* Build the allow-list of base directories under which an outgoing message's
|
||||
* attachments may live. Each room is sandboxed to its own group folder
|
||||
* (`groups/<folder>`) by default; if an explicit `workDir` is registered, that
|
||||
* is added too. This way a screenshot the agent saved under
|
||||
* `groups/<folder>/repo/.artifacts/...` is allowed even when the room has no
|
||||
* workDir configured, while remaining isolated from sibling rooms.
|
||||
*/
|
||||
function getGroupAttachmentBaseDirs(group: RegisteredGroup): string[] {
|
||||
const dirs: string[] = [];
|
||||
try {
|
||||
dirs.push(resolveGroupFolderPath(group.folder));
|
||||
} catch {
|
||||
// Invalid folder names should not block delivery; fall through.
|
||||
}
|
||||
if (group.workDir) dirs.push(group.workDir);
|
||||
return dirs;
|
||||
}
|
||||
export {
|
||||
resolveHandoffCursorKey,
|
||||
resolveHandoffRoleOverride,
|
||||
@@ -255,7 +275,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
channel,
|
||||
item: workItem,
|
||||
log: logger,
|
||||
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
|
||||
attachmentBaseDirs: getGroupAttachmentBaseDirs(group),
|
||||
replaceMessageId,
|
||||
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
||||
openContinuation: (targetChatJid) =>
|
||||
@@ -458,7 +478,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
channel,
|
||||
roleToChannel,
|
||||
log,
|
||||
attachmentBaseDirs: group.workDir ? [group.workDir] : undefined,
|
||||
attachmentBaseDirs: getGroupAttachmentBaseDirs(group),
|
||||
isPairedRoom: hasReviewerLease(chatJid),
|
||||
getMissingRoleChannelMessage,
|
||||
isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal,
|
||||
|
||||
@@ -802,4 +802,30 @@ describe('MessageTurnController outbound audit logging', () => {
|
||||
replaceMessageId: 'progress-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a failure final when an error finishes before any visible output', async () => {
|
||||
const channel = makeChannel();
|
||||
const deliverFinalText = vi.fn().mockResolvedValue(true);
|
||||
const controller = new MessageTurnController({
|
||||
chatJid: 'dc:test-room',
|
||||
group: makeGroup(),
|
||||
runId: 'run-silent-error-final',
|
||||
channel,
|
||||
idleTimeout: 1_000,
|
||||
failureFinalText: '실패',
|
||||
isClaudeCodeAgent: true,
|
||||
clearSession: vi.fn(),
|
||||
requestClose: vi.fn(),
|
||||
deliverFinalText,
|
||||
deliveryRole: 'owner',
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
const finishResult = await controller.finish('error');
|
||||
|
||||
expect(finishResult.visiblePhase).toBe('final');
|
||||
expect(deliverFinalText).toHaveBeenCalledWith('실패', {
|
||||
replaceMessageId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -379,6 +379,12 @@ export class MessageTurnController {
|
||||
this.hadError
|
||||
) {
|
||||
await this.publishFailureFinal();
|
||||
} else if (
|
||||
outputStatus === 'error' &&
|
||||
this.visiblePhase === 'silent' &&
|
||||
!this.terminalObserved()
|
||||
) {
|
||||
await this.publishFailureFinal();
|
||||
}
|
||||
|
||||
this.clearProgressTicker();
|
||||
|
||||
@@ -12,12 +12,14 @@ import { markPairedTaskReviewReady } from './paired-workspace-manager.js';
|
||||
import {
|
||||
applyPairedTaskPatch,
|
||||
hasCodeChangesSinceRef,
|
||||
isUserInputWaitVerdict,
|
||||
parseVisibleVerdict,
|
||||
requestArbiterOrEscalate,
|
||||
resolveOwnerCompletionSignal,
|
||||
resolveCanonicalSourceRef,
|
||||
transitionPairedTaskStatus,
|
||||
} from './paired-execution-context-shared.js';
|
||||
import { shouldSkipReviewerForTrivialTurn } from './paired-trivial-turn-detector.js';
|
||||
import type { PairedTask } from './types.js';
|
||||
|
||||
type OwnerFinalizeOutcome = 'stop' | 're_review';
|
||||
@@ -110,6 +112,29 @@ function handleOwnerFinalizeCompletion(args: {
|
||||
ownerVerdict === 'step_done' && hasNewChanges === false
|
||||
? (task.empty_step_done_streak ?? 0) + 1
|
||||
: 0;
|
||||
|
||||
if (isUserInputWaitVerdict(summary)) {
|
||||
transitionPairedTaskStatus({
|
||||
taskId,
|
||||
currentStatus: task.status,
|
||||
nextStatus: 'completed',
|
||||
expectedUpdatedAt: task.updated_at,
|
||||
updatedAt: now,
|
||||
patch: {
|
||||
completion_reason: 'escalated',
|
||||
owner_failure_count: 0,
|
||||
owner_step_done_streak: 0,
|
||||
finalize_step_done_count: nextFinalizeStepDoneCount,
|
||||
empty_step_done_streak: nextEmptyStepDoneStreak,
|
||||
},
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, ownerVerdict, summary: summary?.slice(0, 100) },
|
||||
'Owner finalize is waiting for user input — completing paired task to stop follow-up loop',
|
||||
);
|
||||
return 'stop';
|
||||
}
|
||||
|
||||
const signal = resolveOwnerCompletionSignal({
|
||||
phase: 'finalize',
|
||||
visibleVerdict: ownerVerdict,
|
||||
@@ -344,6 +369,27 @@ export function handleOwnerCompletion(args: {
|
||||
const ownerVerdict = parseVisibleVerdict(summary);
|
||||
const nextOwnerStepDoneStreak =
|
||||
ownerVerdict === 'step_done' ? (task.owner_step_done_streak ?? 0) + 1 : 0;
|
||||
|
||||
if (isUserInputWaitVerdict(summary)) {
|
||||
transitionPairedTaskStatus({
|
||||
taskId,
|
||||
currentStatus: task.status,
|
||||
nextStatus: 'completed',
|
||||
expectedUpdatedAt: task.updated_at,
|
||||
updatedAt: now,
|
||||
patch: {
|
||||
completion_reason: 'escalated',
|
||||
owner_failure_count: 0,
|
||||
owner_step_done_streak: nextOwnerStepDoneStreak,
|
||||
},
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, ownerVerdict, summary: summary?.slice(0, 100) },
|
||||
'Owner is waiting for user input — completing paired task to stop follow-up loop',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const signal = resolveOwnerCompletionSignal({
|
||||
phase: 'normal',
|
||||
visibleVerdict: ownerVerdict,
|
||||
@@ -381,6 +427,31 @@ export function handleOwnerCompletion(args: {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Skip reviewer for clearly conversational turns (no code changes,
|
||||
// short non-technical reply, no code-work keywords from the user).
|
||||
// The detector is best-effort optimization — if anything goes wrong
|
||||
// we fall back to running the reviewer (the safe pre-change behavior).
|
||||
let skip = false;
|
||||
let skipReason = 'evaluation-error';
|
||||
try {
|
||||
const decision = shouldSkipReviewerForTrivialTurn({ task, summary });
|
||||
skip = decision.skip;
|
||||
skipReason = decision.reason;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, taskId },
|
||||
'paired-trivial-turn-detector: unexpected evaluation error — running reviewer',
|
||||
);
|
||||
}
|
||||
if (skip) {
|
||||
logger.info(
|
||||
{ taskId, reason: skipReason, summary: summary?.slice(0, 100) },
|
||||
'Skipping reviewer for trivial conversational turn',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
maybeAutoTriggerReviewerAfterOwnerCompletion({
|
||||
task,
|
||||
taskId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ARBITER_DEADLOCK_THRESHOLD } from './config.js';
|
||||
import { ARBITER_DEADLOCK_THRESHOLD, isArbiterEnabled } from './config.js';
|
||||
import { getPairedWorkspace } from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
parseVisibleVerdict,
|
||||
parseReviewerVisibleVerdict,
|
||||
isUserInputWaitVerdict,
|
||||
requestArbiterOrEscalate,
|
||||
resolveReviewerCompletionSignal,
|
||||
resolveReviewerFailureSignal,
|
||||
@@ -11,6 +12,12 @@ import {
|
||||
} from './paired-execution-context-shared.js';
|
||||
import type { PairedTask } from './types.js';
|
||||
|
||||
function isReviewerUnavailableFailure(summary: string | null | undefined) {
|
||||
return /(?:\b429\b|usage limit|rate limit|try again at|purchase more credits)/i.test(
|
||||
summary ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
export function handleFailedReviewerExecution(args: {
|
||||
task: PairedTask;
|
||||
taskId: string;
|
||||
@@ -20,7 +27,7 @@ export function handleFailedReviewerExecution(args: {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (summary) {
|
||||
const verdict = parseVisibleVerdict(summary);
|
||||
const verdict = parseReviewerVisibleVerdict(summary);
|
||||
const signal = resolveReviewerFailureSignal({
|
||||
visibleVerdict: verdict,
|
||||
});
|
||||
@@ -67,6 +74,28 @@ export function handleFailedReviewerExecution(args: {
|
||||
}
|
||||
}
|
||||
|
||||
if (isReviewerUnavailableFailure(summary)) {
|
||||
transitionPairedTaskStatus({
|
||||
taskId,
|
||||
currentStatus: task.status,
|
||||
nextStatus: 'completed',
|
||||
expectedUpdatedAt: task.updated_at,
|
||||
updatedAt: now,
|
||||
patch: {
|
||||
completion_reason: 'escalated',
|
||||
},
|
||||
});
|
||||
logger.warn(
|
||||
{
|
||||
taskId,
|
||||
role: 'reviewer',
|
||||
summary: summary?.slice(0, 160),
|
||||
},
|
||||
'Reviewer unavailable due to rate/usage limit — stopping retry loop',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackStatus =
|
||||
task.status === 'in_review' || task.status === 'review_ready'
|
||||
? 'review_ready'
|
||||
@@ -98,11 +127,33 @@ export function handleReviewerCompletion(args: {
|
||||
}): void {
|
||||
const { task, taskId, summary } = args;
|
||||
const now = new Date().toISOString();
|
||||
const verdict = parseVisibleVerdict(summary);
|
||||
const verdict = parseReviewerVisibleVerdict(summary);
|
||||
const deadlockThreshold = isArbiterEnabled()
|
||||
? ARBITER_DEADLOCK_THRESHOLD
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
if (isUserInputWaitVerdict(summary)) {
|
||||
transitionPairedTaskStatus({
|
||||
taskId,
|
||||
currentStatus: task.status,
|
||||
nextStatus: 'completed',
|
||||
expectedUpdatedAt: task.updated_at,
|
||||
updatedAt: now,
|
||||
patch: {
|
||||
completion_reason: 'escalated',
|
||||
},
|
||||
});
|
||||
logger.info(
|
||||
{ taskId, verdict, summary: summary?.slice(0, 100) },
|
||||
'Reviewer is waiting for user input — completing paired task to stop follow-up loop',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const signal = resolveReviewerCompletionSignal({
|
||||
visibleVerdict: verdict,
|
||||
roundTripCount: task.round_trip_count,
|
||||
deadlockThreshold: ARBITER_DEADLOCK_THRESHOLD,
|
||||
deadlockThreshold,
|
||||
});
|
||||
|
||||
switch (signal.kind) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
parseVisibleVerdict,
|
||||
isUserInputWaitVerdict,
|
||||
parseReviewerVisibleVerdict,
|
||||
resolveOwnerCompletionSignal,
|
||||
resolveReviewerCompletionSignal,
|
||||
resolveReviewerFailureSignal,
|
||||
@@ -11,6 +13,8 @@ describe('paired execution context shared verdict helpers', () => {
|
||||
it('parses visible verdicts from the first summary line only', () => {
|
||||
expect(parseVisibleVerdict('STEP_DONE\nmore to do')).toBe('step_done');
|
||||
expect(parseVisibleVerdict('TASK_DONE\nall done')).toBe('task_done');
|
||||
expect(parseVisibleVerdict('APPROVE\nlooks good')).toBe('task_done');
|
||||
expect(parseVisibleVerdict('APPROVED\nlooks good')).toBe('task_done');
|
||||
expect(
|
||||
parseVisibleVerdict(
|
||||
'DONE_WITH_CONCERNS\n\nfollow-up detail that should not affect parsing',
|
||||
@@ -18,6 +22,31 @@ describe('paired execution context shared verdict helpers', () => {
|
||||
).toBe('done_with_concerns');
|
||||
expect(parseVisibleVerdict('BLOCKED\nextra detail')).toBe('blocked');
|
||||
expect(parseVisibleVerdict('random prose')).toBe('continue');
|
||||
expect(parseVisibleVerdict('PROCEED — arbiter-style text')).toBe(
|
||||
'continue',
|
||||
);
|
||||
expect(
|
||||
parseReviewerVisibleVerdict('PROCEED — review approved, no work left'),
|
||||
).toBe('task_done');
|
||||
});
|
||||
|
||||
it('detects STEP_DONE summaries that are only waiting for user input', () => {
|
||||
expect(
|
||||
isUserInputWaitVerdict(
|
||||
'STEP_DONE — 대기 유지. whoami 결과를 보내주면 바로 SSH 로그인 테스트합니다.',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isUserInputWaitVerdict(
|
||||
'STEP_DONE\n키 등록 끝나면 알려주세요. 그 전까지 더 안 보냅니다.',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isUserInputWaitVerdict('STEP_DONE\n1단계 완료, 다음 단계 진행합니다.'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isUserInputWaitVerdict('TASK_DONE\n사용자 입력 대기 없이 완료'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('maps normal owner completion verdicts to reviewer or arbiter signals', () => {
|
||||
@@ -93,6 +122,13 @@ describe('paired execution context shared verdict helpers', () => {
|
||||
});
|
||||
|
||||
it('maps reviewer completion verdicts to finalize, owner changes, or arbiter', () => {
|
||||
expect(
|
||||
resolveReviewerCompletionSignal({
|
||||
visibleVerdict: parseVisibleVerdict('APPROVE\nverified'),
|
||||
roundTripCount: 2,
|
||||
deadlockThreshold: 2,
|
||||
}),
|
||||
).toEqual({ kind: 'request_owner_finalize' });
|
||||
expect(
|
||||
resolveReviewerCompletionSignal({
|
||||
visibleVerdict: 'task_done',
|
||||
|
||||
@@ -3,7 +3,11 @@ import { execFileSync } from 'child_process';
|
||||
import { isArbiterEnabled } from './config.js';
|
||||
import { updatePairedTaskIfUnchanged } from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import { parseVisibleVerdict, type VisibleVerdict } from './paired-verdict.js';
|
||||
import {
|
||||
parseReviewerVisibleVerdict,
|
||||
parseVisibleVerdict,
|
||||
type VisibleVerdict,
|
||||
} from './paired-verdict.js';
|
||||
import type { PairedTaskStatus } from './types.js';
|
||||
|
||||
export type CompletionSignal =
|
||||
@@ -14,9 +18,36 @@ export type CompletionSignal =
|
||||
| { kind: 'complete'; completionReason: 'done' | 'escalated' }
|
||||
| { kind: 'preserve_review_ready' };
|
||||
|
||||
export { parseVisibleVerdict };
|
||||
export { parseReviewerVisibleVerdict, parseVisibleVerdict };
|
||||
export type { VisibleVerdict };
|
||||
|
||||
export function isUserInputWaitVerdict(
|
||||
summary: string | null | undefined,
|
||||
): boolean {
|
||||
if (parseVisibleVerdict(summary) !== 'step_done') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = (summary ?? '')
|
||||
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
|
||||
.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const asksForUserInput =
|
||||
/(대기|기다리|보내\s*주|알려\s*주|주시면|끝나면|완료되면|그 전까지|더 안 보냅니다|추가 메시지 보류|whoami|사용자명|키\s*등록|공개키|인증 정보 부족)/i.test(
|
||||
text,
|
||||
);
|
||||
if (!asksForUserInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/(수정|고쳐|고치|변경|구현|빌드|커밋|푸시|계속\s*진행|다음\s*단계\s*진행|후속\s*작업\s*계속|남은\s*(?:범위|작업))/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveOwnerCompletionSignal(args: {
|
||||
phase: 'normal' | 'finalize';
|
||||
visibleVerdict: VisibleVerdict;
|
||||
|
||||
@@ -875,6 +875,34 @@ describe('paired execution context', () => {
|
||||
).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('completes instead of reviewing when owner STEP_DONE is waiting for user input', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
status: 'active',
|
||||
round_trip_count: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: 'task-1',
|
||||
role: 'owner',
|
||||
status: 'succeeded',
|
||||
summary:
|
||||
'STEP_DONE — 대기 유지. whoami 결과를 보내주면 바로 SSH 로그인 테스트합니다.',
|
||||
});
|
||||
|
||||
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'completed',
|
||||
completion_reason: 'escalated',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
pairedWorkspaceManager.markPairedTaskReviewReady,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests review instead of arbiter when active STEP_DONE repeats without code changes', () => {
|
||||
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
|
||||
|
||||
@@ -1323,6 +1351,98 @@ describe('paired execution context', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('treats reviewer PROCEED as approval instead of owner-change feedback', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
status: 'in_review',
|
||||
source_ref: 'reviewed-ref',
|
||||
}),
|
||||
);
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: 'task-1',
|
||||
role: 'reviewer',
|
||||
status: 'succeeded',
|
||||
summary: 'PROCEED — v0.4.27 종료 상태입니다. 추가 작업 없습니다.',
|
||||
});
|
||||
|
||||
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'merge_ready',
|
||||
source_ref: 'reviewed-ref',
|
||||
}),
|
||||
);
|
||||
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('completes instead of returning to owner when reviewer STEP_DONE is waiting for user input', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
status: 'in_review',
|
||||
source_ref: 'reviewed-ref',
|
||||
}),
|
||||
);
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: 'task-1',
|
||||
role: 'reviewer',
|
||||
status: 'succeeded',
|
||||
summary:
|
||||
'STEP_DONE\n\n대기 중입니다. whoami 결과를 보내주면 바로 SSH 접속 테스트합니다.',
|
||||
});
|
||||
|
||||
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'completed',
|
||||
completion_reason: 'escalated',
|
||||
}),
|
||||
);
|
||||
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns reviewer change requests to owner when arbiter is disabled at the deadlock threshold', () => {
|
||||
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(false);
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
status: 'in_review',
|
||||
round_trip_count: config.ARBITER_DEADLOCK_THRESHOLD,
|
||||
}),
|
||||
);
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: 'task-1',
|
||||
role: 'reviewer',
|
||||
status: 'succeeded',
|
||||
summary: 'REVISE\nowner needs another concrete change',
|
||||
});
|
||||
|
||||
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'completed',
|
||||
completion_reason: 'escalated',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps reviewer tasks review_ready when reviewer execution fails without a terminal verdict', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
@@ -1346,6 +1466,30 @@ describe('paired execution context', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('escalates reviewer usage-limit failures instead of retrying forever', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
status: 'in_review',
|
||||
}),
|
||||
);
|
||||
|
||||
completePairedExecutionContext({
|
||||
taskId: 'task-1',
|
||||
role: 'reviewer',
|
||||
status: 'failed',
|
||||
summary:
|
||||
"You've hit your usage limit. Upgrade to Pro or try again later.\n\nExecution completed without a visible terminal verdict.",
|
||||
});
|
||||
|
||||
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||
'task-1',
|
||||
expect.objectContaining({
|
||||
status: 'completed',
|
||||
completion_reason: 'escalated',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps arbiter tasks arbiter_requested when arbiter execution fails without a terminal verdict', () => {
|
||||
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||
buildPairedTask({
|
||||
|
||||
183
src/paired-trivial-turn-detector.test.ts
Normal file
183
src/paired-trivial-turn-detector.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldSkipReviewerForTrivialTurn } from './paired-trivial-turn-detector.js';
|
||||
import type { NewMessage, PairedTask } from './types.js';
|
||||
|
||||
function makeTask(overrides: Partial<PairedTask> = {}): PairedTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
chat_jid: 'dc:111',
|
||||
group_folder: 'general',
|
||||
owner_service_id: 'svc-owner',
|
||||
reviewer_service_id: 'svc-reviewer',
|
||||
title: null,
|
||||
source_ref: 'abc123',
|
||||
plan_notes: null,
|
||||
review_requested_at: null,
|
||||
round_trip_count: 0,
|
||||
status: 'active',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-05-06T00:00:00Z',
|
||||
updated_at: '2026-05-06T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function userMsg(content: string): NewMessage {
|
||||
return {
|
||||
id: 'm1',
|
||||
chat_jid: 'dc:111',
|
||||
sender: 'user',
|
||||
sender_name: '사용자',
|
||||
content,
|
||||
timestamp: '2026-05-06T00:00:00Z',
|
||||
is_bot_message: false,
|
||||
};
|
||||
}
|
||||
|
||||
const cleanWorkspace = () => false;
|
||||
|
||||
describe('shouldSkipReviewerForTrivialTurn', () => {
|
||||
it('skips review for a short conversational reply with no code changes and a chit-chat user message', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: '오늘 서울 날씨는 맑음입니다.' },
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('서울 날씨 어때?')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(true);
|
||||
expect(decision.reason).toBe('trivial-turn');
|
||||
});
|
||||
|
||||
it('runs reviewer when the workspace has actual code changes', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: '날씨는 맑음' },
|
||||
{
|
||||
detectCodeChanges: () => true,
|
||||
recentMessages: () => [userMsg('서울 날씨 어때?')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('has-code-changes');
|
||||
});
|
||||
|
||||
it('runs reviewer when the owner reply contains a fenced code block', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{
|
||||
task: makeTask(),
|
||||
summary: '예시:\n```ts\nconst x = 1;\n```',
|
||||
},
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('아무거나')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('has-code-block');
|
||||
});
|
||||
|
||||
it('runs reviewer when the owner reply mentions a source file', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{
|
||||
task: makeTask(),
|
||||
summary: 'src/db.ts 에서 처리합니다.',
|
||||
},
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('아무거나')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('mentions-code-file');
|
||||
});
|
||||
|
||||
it('runs reviewer when the user message contains a code-work keyword (Korean)', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: '확인했습니다.' },
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('이거 코드 좀 수정해줘')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toContain('user-keyword');
|
||||
});
|
||||
|
||||
it('runs reviewer when the user message contains a code-work keyword (English)', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: 'OK' },
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('please fix the bug')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toContain('user-keyword');
|
||||
});
|
||||
|
||||
it('runs reviewer when the verdict is DONE_WITH_CONCERNS', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{
|
||||
task: makeTask(),
|
||||
summary: 'DONE_WITH_CONCERNS\nminor issue noticed',
|
||||
},
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('서울 날씨 어때?')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('verdict-done-with-concerns');
|
||||
});
|
||||
|
||||
it('runs reviewer when the owner reply is unusually long', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: 'a'.repeat(900) },
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('서울 날씨 어때?')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('long-summary');
|
||||
});
|
||||
|
||||
it('runs reviewer when the owner reply is empty', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: '' },
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [userMsg('서울 날씨 어때?')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('empty-summary');
|
||||
});
|
||||
|
||||
it('runs reviewer when there is no recent user message (conservative fallback)', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: '안녕하세요!' },
|
||||
{
|
||||
detectCodeChanges: cleanWorkspace,
|
||||
recentMessages: () => [],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('no-user-message');
|
||||
});
|
||||
|
||||
it('runs reviewer when code-change status cannot be determined', () => {
|
||||
const decision = shouldSkipReviewerForTrivialTurn(
|
||||
{ task: makeTask(), summary: '오늘 서울 날씨는 맑음입니다.' },
|
||||
{
|
||||
detectCodeChanges: () => null,
|
||||
recentMessages: () => [userMsg('서울 날씨 어때?')],
|
||||
},
|
||||
);
|
||||
expect(decision.skip).toBe(false);
|
||||
expect(decision.reason).toBe('code-change-status-unknown');
|
||||
});
|
||||
});
|
||||
214
src/paired-trivial-turn-detector.ts
Normal file
214
src/paired-trivial-turn-detector.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Trivial-turn detector for paired (tribunal) mode.
|
||||
*
|
||||
* Goal: skip the reviewer for owner turns that are obviously conversational
|
||||
* (asking the time, the weather, "what does X mean", chit-chat) so we don't
|
||||
* burn an extra Claude turn on every trivial reply.
|
||||
*
|
||||
* A turn is considered "trivial" only when ALL of the following hold:
|
||||
* 1. There are no actual code changes in the owner workspace
|
||||
* (`git diff --quiet <source_ref> HEAD` is clean).
|
||||
* 2. The owner's reply itself looks conversational — short, no fenced
|
||||
* code block, no source-file mentions.
|
||||
* 3. The most recent user message contains no "code-work" keywords
|
||||
* (수정/구현/빌드/푸시/fix/implement/build/...).
|
||||
* 4. The owner verdict is not `done_with_concerns` (which explicitly
|
||||
* asks the reviewer to look again).
|
||||
*
|
||||
* If any of these fails, fall back to the normal behavior (run the
|
||||
* reviewer). Bias toward running the reviewer when uncertain — skipping
|
||||
* incorrectly could hide a real issue, while reviewing unnecessarily only
|
||||
* costs tokens.
|
||||
*/
|
||||
import { getPairedWorkspace, getRecentChatMessages } from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
hasCodeChangesSinceRef,
|
||||
parseVisibleVerdict,
|
||||
} from './paired-execution-context-shared.js';
|
||||
import type { NewMessage, PairedTask } from './types.js';
|
||||
|
||||
export interface TrivialTurnDeps {
|
||||
/** Returns true if there are uncommitted/committed code changes since
|
||||
* the task source ref. Defaults to running `git diff` against the
|
||||
* paired workspace. Override in tests. */
|
||||
detectCodeChanges?: (task: PairedTask) => boolean | null;
|
||||
/** Returns recent messages for the chat (newest at the end). Defaults
|
||||
* to `getRecentChatMessages`. Override in tests. */
|
||||
recentMessages?: (chatJid: string, limit: number) => NewMessage[];
|
||||
}
|
||||
|
||||
const MAX_TRIVIAL_SUMMARY_LENGTH = 800;
|
||||
|
||||
const CODE_WORK_KEYWORDS: readonly string[] = [
|
||||
// Korean — code/work intents
|
||||
'수정',
|
||||
'변경',
|
||||
'추가',
|
||||
'제거',
|
||||
'삭제',
|
||||
'만들어',
|
||||
'구현',
|
||||
'빌드',
|
||||
'배포',
|
||||
'푸시',
|
||||
'커밋',
|
||||
'리팩토',
|
||||
'리팩터',
|
||||
'리팩',
|
||||
'버그',
|
||||
'에러',
|
||||
'오류',
|
||||
'고쳐',
|
||||
'고치',
|
||||
'등록',
|
||||
'적용',
|
||||
'재시작',
|
||||
'설치',
|
||||
'테스트',
|
||||
'실행',
|
||||
'디버그',
|
||||
'분석',
|
||||
'리뷰',
|
||||
'검증',
|
||||
'롤백',
|
||||
'머지',
|
||||
'코드',
|
||||
'파일',
|
||||
'함수',
|
||||
'모듈',
|
||||
'커서',
|
||||
'스크립트',
|
||||
'의존성',
|
||||
'패키지',
|
||||
'컴파일',
|
||||
'타입',
|
||||
'인터페이스',
|
||||
// English
|
||||
'fix',
|
||||
'implement',
|
||||
'create ',
|
||||
'build',
|
||||
'deploy',
|
||||
'push',
|
||||
'commit',
|
||||
'refactor',
|
||||
'bug',
|
||||
'error',
|
||||
'debug',
|
||||
'install',
|
||||
'restart',
|
||||
'rollback',
|
||||
'merge',
|
||||
'review',
|
||||
'compile',
|
||||
'typecheck',
|
||||
'lint',
|
||||
'pull request',
|
||||
' pr ',
|
||||
'test ',
|
||||
'tests',
|
||||
' run ',
|
||||
'script',
|
||||
'function',
|
||||
'module',
|
||||
'package',
|
||||
];
|
||||
|
||||
const CODE_FILE_EXT_REGEX =
|
||||
/\b\w+\.(ts|tsx|js|jsx|mjs|cjs|json|py|go|rs|cpp|cc|c|h|hpp|java|kt|swift|rb|php|sh|yml|yaml|toml|md|html|css|scss|sql)\b/i;
|
||||
const SOURCE_PATH_REGEX = /(?:^|[\s`'"(])(?:src|tests?|scripts?)\/[\w./-]+/;
|
||||
|
||||
export interface TrivialTurnDecision {
|
||||
skip: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function shouldSkipReviewerForTrivialTurn(
|
||||
args: {
|
||||
task: PairedTask;
|
||||
summary?: string | null;
|
||||
},
|
||||
deps: TrivialTurnDeps = {},
|
||||
): TrivialTurnDecision {
|
||||
const verdict = parseVisibleVerdict(args.summary);
|
||||
if (verdict === 'done_with_concerns') {
|
||||
return { skip: false, reason: 'verdict-done-with-concerns' };
|
||||
}
|
||||
|
||||
// Real code changes always require review.
|
||||
const detectCodeChanges = deps.detectCodeChanges ?? defaultDetectCodeChanges;
|
||||
let codeChanges: boolean | null = null;
|
||||
try {
|
||||
codeChanges = detectCodeChanges(args.task);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, taskId: args.task.id },
|
||||
'paired-trivial-turn-detector: workspace diff lookup failed',
|
||||
);
|
||||
return { skip: false, reason: 'diff-lookup-failed' };
|
||||
}
|
||||
if (codeChanges === true) {
|
||||
return { skip: false, reason: 'has-code-changes' };
|
||||
}
|
||||
if (codeChanges === null) {
|
||||
// Couldn't determine — be conservative and run the reviewer.
|
||||
return { skip: false, reason: 'code-change-status-unknown' };
|
||||
}
|
||||
|
||||
const summary = (args.summary ?? '').trim();
|
||||
if (summary.length === 0) {
|
||||
return { skip: false, reason: 'empty-summary' };
|
||||
}
|
||||
if (summary.length > MAX_TRIVIAL_SUMMARY_LENGTH) {
|
||||
return { skip: false, reason: 'long-summary' };
|
||||
}
|
||||
if (summary.includes('```')) {
|
||||
return { skip: false, reason: 'has-code-block' };
|
||||
}
|
||||
if (CODE_FILE_EXT_REGEX.test(summary)) {
|
||||
return { skip: false, reason: 'mentions-code-file' };
|
||||
}
|
||||
if (SOURCE_PATH_REGEX.test(summary)) {
|
||||
return { skip: false, reason: 'mentions-source-path' };
|
||||
}
|
||||
|
||||
// Look at the most recent non-bot message (the user request that
|
||||
// triggered this owner turn). If it contains code-work keywords, run
|
||||
// the reviewer.
|
||||
const recentMessages = deps.recentMessages ?? getRecentChatMessages;
|
||||
let userText = '';
|
||||
try {
|
||||
const recent = recentMessages(args.task.chat_jid, 10);
|
||||
for (let i = recent.length - 1; i >= 0; i -= 1) {
|
||||
const msg = recent[i];
|
||||
if (!msg.is_bot_message) {
|
||||
userText = (msg.content ?? '').toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, taskId: args.task.id },
|
||||
'paired-trivial-turn-detector: recent message lookup failed',
|
||||
);
|
||||
return { skip: false, reason: 'recent-message-lookup-failed' };
|
||||
}
|
||||
if (!userText) {
|
||||
// No identifiable user message — be conservative and run the reviewer.
|
||||
return { skip: false, reason: 'no-user-message' };
|
||||
}
|
||||
for (const keyword of CODE_WORK_KEYWORDS) {
|
||||
if (userText.includes(keyword.toLowerCase())) {
|
||||
return { skip: false, reason: `user-keyword:${keyword.trim()}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { skip: true, reason: 'trivial-turn' };
|
||||
}
|
||||
|
||||
function defaultDetectCodeChanges(task: PairedTask): boolean | null {
|
||||
const workspace = getPairedWorkspace(task.id, 'owner');
|
||||
if (!workspace?.workspace_dir) return null;
|
||||
return hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref);
|
||||
}
|
||||
@@ -7,17 +7,23 @@ export type VisibleVerdict =
|
||||
| 'needs_context'
|
||||
| 'continue';
|
||||
|
||||
function firstVisibleLine(summary: string | null | undefined): string | null {
|
||||
if (!summary) return null;
|
||||
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
if (!cleaned) return null;
|
||||
return cleaned.split('\n')[0].trim();
|
||||
}
|
||||
|
||||
export function parseVisibleVerdict(
|
||||
summary: string | null | undefined,
|
||||
): VisibleVerdict {
|
||||
if (!summary) return 'continue';
|
||||
const cleaned = summary.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
if (!cleaned) return 'continue';
|
||||
const firstLine = cleaned.split('\n')[0].trim();
|
||||
const firstLine = firstVisibleLine(summary);
|
||||
if (!firstLine) return 'continue';
|
||||
if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked';
|
||||
if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context';
|
||||
if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done';
|
||||
if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done';
|
||||
if (/^\*{0,2}APPROV(?:E|ED)\.?\*{0,2}\b/i.test(firstLine)) return 'task_done';
|
||||
if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine))
|
||||
return 'done_with_concerns';
|
||||
if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done';
|
||||
@@ -26,6 +32,20 @@ export function parseVisibleVerdict(
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
export function parseReviewerVisibleVerdict(
|
||||
summary: string | null | undefined,
|
||||
): VisibleVerdict {
|
||||
const verdict = parseVisibleVerdict(summary);
|
||||
if (verdict !== 'continue') return verdict;
|
||||
|
||||
const firstLine = firstVisibleLine(summary);
|
||||
if (/^\*{0,2}PROCEED\.?\*{0,2}\b/i.test(firstLine ?? '')) {
|
||||
return 'task_done';
|
||||
}
|
||||
|
||||
return verdict;
|
||||
}
|
||||
|
||||
export function resolveStoredVisibleVerdict(args: {
|
||||
verdict?: VisibleVerdict | null;
|
||||
outputText?: string | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildInterruptedRestartAnnouncement,
|
||||
getInterruptedRecoveryCandidates,
|
||||
type RestartContext,
|
||||
} from './restart-context.js';
|
||||
@@ -36,6 +37,7 @@ describe('getInterruptedRecoveryCandidates', () => {
|
||||
elapsedMs: 1000,
|
||||
pendingMessages: true,
|
||||
pendingTasks: 0,
|
||||
rewindToSeq: 42,
|
||||
},
|
||||
{
|
||||
chatJid: 'dc:1',
|
||||
@@ -71,6 +73,7 @@ describe('getInterruptedRecoveryCandidates', () => {
|
||||
status: 'processing',
|
||||
pendingMessages: true,
|
||||
pendingTasks: 0,
|
||||
rewindToSeq: 42,
|
||||
},
|
||||
{
|
||||
chatJid: 'dc:2',
|
||||
@@ -78,6 +81,7 @@ describe('getInterruptedRecoveryCandidates', () => {
|
||||
status: 'idle',
|
||||
pendingMessages: false,
|
||||
pendingTasks: 0,
|
||||
rewindToSeq: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -86,3 +90,33 @@ describe('getInterruptedRecoveryCandidates', () => {
|
||||
expect(getInterruptedRecoveryCandidates(null, {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInterruptedRestartAnnouncement', () => {
|
||||
it('promises auto-resume when a rewind seq is captured', () => {
|
||||
const message = buildInterruptedRestartAnnouncement({
|
||||
chatJid: 'dc:1',
|
||||
groupName: 'room',
|
||||
status: 'processing',
|
||||
elapsedMs: 12000,
|
||||
pendingMessages: true,
|
||||
pendingTasks: 0,
|
||||
rewindToSeq: 100,
|
||||
});
|
||||
expect(message).toContain('서비스 재시작으로 이전 작업이 중단됐습니다.');
|
||||
expect(message).toContain('자동으로 이어갑니다');
|
||||
expect(message).not.toContain('필요하면 이어서');
|
||||
});
|
||||
|
||||
it('falls back to manual prompt when no rewind seq exists', () => {
|
||||
const message = buildInterruptedRestartAnnouncement({
|
||||
chatJid: 'dc:1',
|
||||
groupName: 'room',
|
||||
status: 'waiting',
|
||||
elapsedMs: null,
|
||||
pendingMessages: false,
|
||||
pendingTasks: 0,
|
||||
});
|
||||
expect(message).toContain('필요하면 이어서');
|
||||
expect(message).not.toContain('자동으로 이어갑니다');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface RestartInterruptedGroup {
|
||||
elapsedMs: number | null;
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
/**
|
||||
* Seq of the earliest unanswered human message at shutdown time. If
|
||||
* present, startup will rewind the agent cursor to (this seq − 1) so the
|
||||
* interrupted turn re-runs automatically. Null when nothing was waiting.
|
||||
*/
|
||||
rewindToSeq?: number | null;
|
||||
}
|
||||
|
||||
export interface RestartContext {
|
||||
@@ -37,6 +43,7 @@ export interface RestartRecoveryCandidate {
|
||||
status: RestartInterruptedGroup['status'];
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
rewindToSeq: number | null;
|
||||
}
|
||||
|
||||
const INFER_WINDOW_MS = 3 * 60 * 1000;
|
||||
@@ -197,7 +204,11 @@ export function buildInterruptedRestartAnnouncement(
|
||||
if (interrupted.pendingTasks > 0) {
|
||||
lines.push(`- 대기 태스크: ${interrupted.pendingTasks}개`);
|
||||
}
|
||||
if (interrupted.rewindToSeq != null) {
|
||||
lines.push('- 중단된 작업을 자동으로 이어갑니다.');
|
||||
} else {
|
||||
lines.push('- 필요하면 이어서 요청해 주세요.');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -251,6 +262,7 @@ export function getInterruptedRecoveryCandidates(
|
||||
status: interrupted.status,
|
||||
pendingMessages: interrupted.pendingMessages,
|
||||
pendingTasks: interrupted.pendingTasks,
|
||||
rewindToSeq: interrupted.rewindToSeq ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findChannelForDeliveryRole,
|
||||
formatOutbound,
|
||||
neutralizeStrayBackticks,
|
||||
neutralizeStrayMarkdown,
|
||||
resolveChannelForDeliveryRole,
|
||||
} from './router.js';
|
||||
import { type Channel } from './types.js';
|
||||
@@ -92,3 +95,134 @@ describe('findChannelForDeliveryRole', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('neutralizeStrayMarkdown', () => {
|
||||
it('strips inline backticks', () => {
|
||||
expect(neutralizeStrayMarkdown('use `foo` and `bar` here')).toBe(
|
||||
'use foo and bar here',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves well-formed fenced code blocks', () => {
|
||||
const input = 'before\n```ts\nconst x = `tpl`;\n```\nafter `inline`';
|
||||
expect(neutralizeStrayMarkdown(input)).toBe(
|
||||
'before\n```ts\nconst x = `tpl`;\n```\nafter inline',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips emphasis in an unterminated fence', () => {
|
||||
expect(neutralizeStrayMarkdown('oops ```code without close')).toBe(
|
||||
'oops code without close',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through plain text unchanged', () => {
|
||||
const s = 'plain text with no markup';
|
||||
expect(neutralizeStrayMarkdown(s)).toBe(s);
|
||||
});
|
||||
|
||||
it('strips italic asterisks', () => {
|
||||
expect(neutralizeStrayMarkdown('이건 *기울임* 처리')).toBe(
|
||||
'이건 기울임 처리',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips bold asterisks', () => {
|
||||
expect(neutralizeStrayMarkdown('이건 **강조** 처리')).toBe(
|
||||
'이건 강조 처리',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips bold-italic triple asterisks', () => {
|
||||
expect(neutralizeStrayMarkdown('***hybrid***')).toBe('hybrid');
|
||||
});
|
||||
|
||||
it('strips italic underscores at word boundaries', () => {
|
||||
expect(neutralizeStrayMarkdown('이건 _italic_ 처리')).toBe(
|
||||
'이건 italic 처리',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves single underscores in snake_case identifiers', () => {
|
||||
expect(neutralizeStrayMarkdown('use foo_bar_baz here')).toBe(
|
||||
'use foo_bar_baz here',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes underscores in absolute paths so Discord does not italicize them', () => {
|
||||
expect(
|
||||
neutralizeStrayMarkdown(
|
||||
'경로: /home/claude/EJClaw/groups/mc_the_scene_plugin/src/main_file.ts',
|
||||
),
|
||||
).toBe(
|
||||
'경로: /home/claude/EJClaw/groups/mc\\_the\\_scene\\_plugin/src/main\\_file.ts',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes underscores in Windows paths', () => {
|
||||
expect(
|
||||
neutralizeStrayMarkdown(
|
||||
'경로: C:\\Custom_vscode\\minecraft_launcher\\src\\main_file.ts',
|
||||
),
|
||||
).toBe(
|
||||
'경로: C:\\Custom\\_vscode\\minecraft\\_launcher\\src\\main\\_file.ts',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips double-underscore underline markers', () => {
|
||||
expect(neutralizeStrayMarkdown('__bold__ text')).toBe('bold text');
|
||||
});
|
||||
|
||||
it('strips heading hashes at line start', () => {
|
||||
expect(neutralizeStrayMarkdown('## 빌드 타임 메타\n본문')).toBe(
|
||||
'빌드 타임 메타\n본문',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips block-quote markers', () => {
|
||||
expect(neutralizeStrayMarkdown('> quoted line\n> another')).toBe(
|
||||
'quoted line\nanother',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips strikethrough', () => {
|
||||
expect(neutralizeStrayMarkdown('이건 ~~취소선~~ 입니다')).toBe(
|
||||
'이건 취소선 입니다',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips spoiler markers', () => {
|
||||
expect(neutralizeStrayMarkdown('||비밀||')).toBe('비밀');
|
||||
});
|
||||
|
||||
it('preserves Discord mention syntax', () => {
|
||||
expect(neutralizeStrayMarkdown('<@123> hi <#456>')).toBe(
|
||||
'<@123> hi <#456>',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves code fence content while stripping prose emphasis', () => {
|
||||
const input = '**bold** outside\n```py\nx = *2\n```\nand *more*';
|
||||
expect(neutralizeStrayMarkdown(input)).toBe(
|
||||
'bold outside\n```py\nx = *2\n```\nand more',
|
||||
);
|
||||
});
|
||||
|
||||
it('back-compat alias still exported', () => {
|
||||
expect(neutralizeStrayBackticks('*x*')).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOutbound', () => {
|
||||
it('runs the full pipeline and strips stray backticks', () => {
|
||||
const raw = '<internal>note</internal>Use `foo` in your code.';
|
||||
expect(formatOutbound(raw)).toBe('Use foo in your code.');
|
||||
});
|
||||
|
||||
it('preserves fenced code blocks through the pipeline', () => {
|
||||
const raw = 'See:\n```js\nconst a = 1;\n```\nor `inline`.';
|
||||
expect(formatOutbound(raw)).toBe(
|
||||
'See:\n```js\nconst a = 1;\n```\nor inline.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,12 +78,90 @@ export function stripToolCallLeaks(text: string): string {
|
||||
return stripped.replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function escapePathMarkdownUnderscores(segment: string): string {
|
||||
return segment.replace(
|
||||
/(^|[\s[{<])((?:[A-Za-z]:[\\/]|~?[\\/]|\.{1,2}[\\/])\S*_\S*_\S*)/g,
|
||||
(_match, prefix: string, filePath: string) =>
|
||||
`${prefix}${filePath.replace(/_/g, '\\_')}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Discord markdown emphasis/heading markers that mangle plain-prose
|
||||
* output when wrapping across lines. Used inside non-code segments only —
|
||||
* callers should preserve fenced code blocks separately.
|
||||
*
|
||||
* Discord rendering reference:
|
||||
* *italic*, _italic_, **bold**, ***bold-italic***
|
||||
* __underline__ ~~strike~~ ||spoiler||
|
||||
* # H1 ## H2 ### H3 (at line start)
|
||||
* > quote, >>> multi-line quote (at line start)
|
||||
*
|
||||
* `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links are left
|
||||
* untouched (handled elsewhere or harmless as raw text).
|
||||
*/
|
||||
function stripMarkdownInProse(segment: string): string {
|
||||
return (
|
||||
escapePathMarkdownUnderscores(segment)
|
||||
// Inline backtick code — agents abuse this to highlight identifiers
|
||||
// and Discord then fragments coloring across line wraps.
|
||||
.replace(/`/g, '')
|
||||
// Strikethrough and spoiler wrappers (always paired punctuation)
|
||||
.replace(/~~/g, '')
|
||||
.replace(/\|\|/g, '')
|
||||
// Heading markers at the start of a line (after optional whitespace)
|
||||
.replace(/^[ \t]*#{1,3}[ \t]+/gm, '')
|
||||
// Block quote markers at the start of a line
|
||||
.replace(/^[ \t]*>{1,3}[ \t]?/gm, '')
|
||||
// Bold/italic asterisks — Discord italicizes even mid-word (a*b*c),
|
||||
// so just remove every '*'. Math/code uses live in fenced blocks.
|
||||
.replace(/\*/g, '')
|
||||
// Underline/bold uses '__'; remove paired runs of 2+ underscores.
|
||||
.replace(/_{2,}/g, '')
|
||||
// Italic '_word_' — only strip when the underscores are at word
|
||||
// boundaries (so snake_case identifiers survive intact).
|
||||
.replace(/(^|[^A-Za-z0-9_])_([^_\n]+?)_(?=$|[^A-Za-z0-9_])/g, '$1$2')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralize stray Discord markdown in prose while preserving well-formed
|
||||
* triple-backtick fenced code blocks (legit code snippets).
|
||||
*/
|
||||
export function neutralizeStrayMarkdown(text: string): string {
|
||||
if (!text) return text;
|
||||
const parts: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const fenceStart = text.indexOf('```', i);
|
||||
if (fenceStart === -1) {
|
||||
parts.push(stripMarkdownInProse(text.slice(i)));
|
||||
break;
|
||||
}
|
||||
parts.push(stripMarkdownInProse(text.slice(i, fenceStart)));
|
||||
const fenceEnd = text.indexOf('```', fenceStart + 3);
|
||||
if (fenceEnd === -1) {
|
||||
// Unterminated fence — not a real code block; treat as prose.
|
||||
parts.push(stripMarkdownInProse(text.slice(fenceStart)));
|
||||
break;
|
||||
}
|
||||
// Preserve the entire fenced block including delimiters.
|
||||
parts.push(text.slice(fenceStart, fenceEnd + 3));
|
||||
i = fenceEnd + 3;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */
|
||||
export const neutralizeStrayBackticks = neutralizeStrayMarkdown;
|
||||
|
||||
export function formatOutbound(rawText: string): string {
|
||||
let text = stripInternalTags(rawText);
|
||||
if (!text) return '';
|
||||
text = stripToolCallLeaks(text);
|
||||
if (!text) return '';
|
||||
return redactSecrets(text);
|
||||
text = redactSecrets(text);
|
||||
return neutralizeStrayMarkdown(text);
|
||||
}
|
||||
|
||||
export function findChannel(
|
||||
|
||||
36
src/types.ts
36
src/types.ts
@@ -9,6 +9,14 @@ export interface AgentConfig {
|
||||
claudeEffort?: string;
|
||||
claudeThinking?: 'adaptive' | 'enabled' | 'disabled';
|
||||
claudeThinkingBudget?: number;
|
||||
// Per-role overrides for paired rooms. When a roomRoleContext is supplied
|
||||
// these take precedence over the flat claudeModel/claudeEffort/codexModel/
|
||||
// codexEffort fields. Useful for spending Opus only on arbiter judgments
|
||||
// while keeping owner/reviewer on Sonnet.
|
||||
claudeModelByRole?: Partial<Record<PairedRoomRole, string>>;
|
||||
claudeEffortByRole?: Partial<Record<PairedRoomRole, string>>;
|
||||
codexModelByRole?: Partial<Record<PairedRoomRole, string>>;
|
||||
codexEffortByRole?: Partial<Record<PairedRoomRole, string>>;
|
||||
}
|
||||
|
||||
export type AgentType = 'claude-code' | 'codex';
|
||||
@@ -40,6 +48,21 @@ export interface SendMessageOptions {
|
||||
* the global Discord attachment allowlist.
|
||||
*/
|
||||
attachmentBaseDirs?: string[];
|
||||
/**
|
||||
* If true, skip Discord markdown neutralization (used by the status
|
||||
* dashboard and other callers that build pre-formatted content with
|
||||
* intentional bold/italic/heading markup). Default: false.
|
||||
*/
|
||||
rawMarkdown?: boolean;
|
||||
}
|
||||
|
||||
export interface EditMessageOptions {
|
||||
/**
|
||||
* If true, skip Discord markdown neutralization for this edit. Default:
|
||||
* false — the streaming edit path will sanitize agent prose the same way
|
||||
* as the initial send.
|
||||
*/
|
||||
rawMarkdown?: boolean;
|
||||
}
|
||||
|
||||
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
|
||||
@@ -264,9 +287,18 @@ export interface Channel {
|
||||
// Optional: typing indicator. Channels that support it implement it.
|
||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||
// Optional: edit/delete messages (used by status dashboard and tracked progress cleanup).
|
||||
editMessage?(jid: string, messageId: string, text: string): Promise<void>;
|
||||
editMessage?(
|
||||
jid: string,
|
||||
messageId: string,
|
||||
text: string,
|
||||
options?: EditMessageOptions,
|
||||
): Promise<void>;
|
||||
deleteMessage?(jid: string, messageId: string): Promise<void>;
|
||||
sendAndTrack?(jid: string, text: string): Promise<string | null>;
|
||||
sendAndTrack?(
|
||||
jid: string,
|
||||
text: string,
|
||||
options?: EditMessageOptions,
|
||||
): Promise<string | null>;
|
||||
// Optional: sync group/chat names from the platform.
|
||||
syncGroups?(force: boolean): Promise<void>;
|
||||
// Optional: get channel metadata (position, category) for ordering.
|
||||
|
||||
@@ -89,23 +89,101 @@ describe('renderUsageTable', () => {
|
||||
expect(codexIdx).toBeGreaterThan(sepIdx);
|
||||
});
|
||||
|
||||
it('omits separator when only Claude rows exist', () => {
|
||||
it('shows "Codex: 조회 불가" when only Claude rows exist', () => {
|
||||
const lines = renderUsageTable([claudeRow], []);
|
||||
|
||||
expect(lines.some((l) => /^─+$/.test(l))).toBe(false);
|
||||
expect(lines.some((l) => l.includes('Claude'))).toBe(true);
|
||||
expect(
|
||||
lines.some((l) => l.includes('Codex') && l.includes('조회 불가')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('omits separator when only Codex rows exist', () => {
|
||||
it('shows "Claude: 조회 불가" when only Codex rows exist', () => {
|
||||
const lines = renderUsageTable([], [codexRow]);
|
||||
|
||||
expect(lines.some((l) => /^─+$/.test(l))).toBe(false);
|
||||
expect(
|
||||
lines.some((l) => l.includes('Claude') && l.includes('조회 불가')),
|
||||
).toBe(true);
|
||||
expect(lines.some((l) => l.includes('Codex'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns fallback text when both groups are empty', () => {
|
||||
it('shows separate 조회 불가 for both when both groups are empty', () => {
|
||||
const lines = renderUsageTable([], []);
|
||||
expect(lines).toEqual(['_조회 불가_']);
|
||||
expect(
|
||||
lines.some((l) => l.includes('Claude') && l.includes('조회 불가')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
lines.some((l) => l.includes('Codex') && l.includes('조회 불가')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('shows "Claude: 사용량 없음" when all Claude rows are at >=99% in a window', () => {
|
||||
const exhaustedClaude: UsageRow = {
|
||||
name: 'Claude pro',
|
||||
h5pct: 99,
|
||||
h5reset: '',
|
||||
d7pct: 30,
|
||||
d7reset: '',
|
||||
};
|
||||
const lines = renderUsageTable([exhaustedClaude], [codexRow]);
|
||||
|
||||
expect(
|
||||
lines.some((l) => l.includes('Claude') && l.includes('사용량 없음')),
|
||||
).toBe(true);
|
||||
// Codex side should still render normally
|
||||
expect(lines.some((l) => l.includes('Codex') && /\d+%/.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it('shows "Codex: 사용량 없음" when all Codex rows are at 100% in either window', () => {
|
||||
const exhaustedCodex: UsageRow = {
|
||||
name: 'Codex',
|
||||
h5pct: 100,
|
||||
h5reset: '',
|
||||
d7pct: 50,
|
||||
d7reset: '',
|
||||
};
|
||||
const lines = renderUsageTable([claudeRow], [exhaustedCodex]);
|
||||
|
||||
expect(
|
||||
lines.some((l) => l.includes('Codex') && l.includes('사용량 없음')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats <99% rows as having capacity remaining', () => {
|
||||
const almostExhausted: UsageRow = {
|
||||
name: 'Claude pro',
|
||||
h5pct: 98,
|
||||
h5reset: '',
|
||||
d7pct: 50,
|
||||
d7reset: '',
|
||||
};
|
||||
const lines = renderUsageTable([almostExhausted], [codexRow]);
|
||||
|
||||
// Should render the row as data, not the "사용량 없음" status line
|
||||
expect(lines.some((l) => l.includes('사용량 없음'))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats source as exhausted only when ALL its rows are exhausted', () => {
|
||||
const exhausted: UsageRow = {
|
||||
name: 'Claude max',
|
||||
h5pct: 100,
|
||||
h5reset: '',
|
||||
d7pct: 100,
|
||||
d7reset: '',
|
||||
};
|
||||
const fresh: UsageRow = {
|
||||
name: 'Claude pro',
|
||||
h5pct: 10,
|
||||
h5reset: '',
|
||||
d7pct: 5,
|
||||
d7reset: '',
|
||||
};
|
||||
const lines = renderUsageTable([exhausted, fresh], [codexRow]);
|
||||
|
||||
// Mixed exhausted/fresh → render rows normally, no 사용량 없음 banner
|
||||
expect(lines.some((l) => l.includes('사용량 없음'))).toBe(false);
|
||||
expect(lines.some((l) => l.includes('Claude max'))).toBe(true);
|
||||
expect(lines.some((l) => l.includes('Claude pro'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
refreshAllCodexAccountUsage,
|
||||
} from './codex-usage-collector.js';
|
||||
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||
import { evaluateAndApplyAutoPairedMode } from './auto-paired-mode.js';
|
||||
import {
|
||||
composeDashboardContent,
|
||||
formatElapsed,
|
||||
@@ -448,12 +449,44 @@ function buildStatusContent(): string {
|
||||
* Ordering: Claude rows → separator → Codex rows.
|
||||
* Exported for testing.
|
||||
*/
|
||||
/**
|
||||
* A row is considered "exhausted" (1% or less remaining in any window) when
|
||||
* either the 5h or 7d utilization is at or above 99%. Rows where a window's
|
||||
* percentage is unknown (h5pct/d7pct < 0) aren't counted toward exhaustion
|
||||
* for that window.
|
||||
*/
|
||||
function isExhaustedRow(row: UsageRow): boolean {
|
||||
const h5Out = row.h5pct >= 99;
|
||||
const d7Out = row.d7pct >= 99;
|
||||
return h5Out || d7Out;
|
||||
}
|
||||
|
||||
function allRowsExhausted(rows: UsageRow[]): boolean {
|
||||
if (rows.length === 0) return false;
|
||||
return rows.every(isExhaustedRow);
|
||||
}
|
||||
|
||||
export function renderUsageTable(
|
||||
claudeBotRows: UsageRow[],
|
||||
codexBotRows: UsageRow[],
|
||||
): string[] {
|
||||
const allRows = [...claudeBotRows, ...codexBotRows];
|
||||
if (allRows.length === 0) return ['_조회 불가_'];
|
||||
// Per-source status: 조회 불가 (no rows) vs 사용량 없음 (all exhausted)
|
||||
const claudeStatus: 'ok' | 'unavailable' | 'exhausted' =
|
||||
claudeBotRows.length === 0
|
||||
? 'unavailable'
|
||||
: allRowsExhausted(claudeBotRows)
|
||||
? 'exhausted'
|
||||
: 'ok';
|
||||
const codexStatus: 'ok' | 'unavailable' | 'exhausted' =
|
||||
codexBotRows.length === 0
|
||||
? 'unavailable'
|
||||
: allRowsExhausted(codexBotRows)
|
||||
? 'exhausted'
|
||||
: 'ok';
|
||||
|
||||
const renderableClaude = claudeStatus === 'ok' ? claudeBotRows : [];
|
||||
const renderableCodex = codexStatus === 'ok' ? codexBotRows : [];
|
||||
const allRenderable = [...renderableClaude, ...renderableCodex];
|
||||
|
||||
const bar = (pct: number) => {
|
||||
const filled = Math.max(0, Math.min(5, Math.round(pct / 20)));
|
||||
@@ -463,12 +496,15 @@ export function renderUsageTable(
|
||||
const visualWidth = (s: string) =>
|
||||
[...s].reduce((w, c) => w + (c.codePointAt(0)! > 0x7f ? 2 : 1), 0);
|
||||
const maxNameWidth =
|
||||
Math.max(8, ...allRows.map((r) => visualWidth(r.name))) + 1;
|
||||
Math.max(8, ...allRenderable.map((r) => visualWidth(r.name))) + 1;
|
||||
const padName = (s: string) =>
|
||||
s + ' '.repeat(Math.max(0, maxNameWidth - visualWidth(s)));
|
||||
const compactReset = (s: string) =>
|
||||
s ? s.replace(/\s+/g, '').replace(/m$/, '') : '';
|
||||
|
||||
const statusLabel = (status: 'unavailable' | 'exhausted'): string =>
|
||||
status === 'unavailable' ? '조회 불가' : '사용량 없음';
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
const renderRows = (rows: UsageRow[]) => {
|
||||
@@ -498,14 +534,22 @@ export function renderUsageTable(
|
||||
lines.push('```');
|
||||
lines.push(`${' '.repeat(maxNameWidth)}5h 7d`);
|
||||
|
||||
// Claude section
|
||||
if (claudeStatus === 'ok') {
|
||||
renderRows(claudeBotRows);
|
||||
|
||||
if (claudeBotRows.length > 0 && codexBotRows.length > 0) {
|
||||
const separatorWidth = maxNameWidth + 20;
|
||||
lines.push('─'.repeat(separatorWidth));
|
||||
} else {
|
||||
lines.push(`${padName('Claude')}${statusLabel(claudeStatus)}`);
|
||||
}
|
||||
|
||||
const separatorWidth = maxNameWidth + 20;
|
||||
lines.push('─'.repeat(separatorWidth));
|
||||
|
||||
// Codex section
|
||||
if (codexStatus === 'ok') {
|
||||
renderRows(codexBotRows);
|
||||
} else {
|
||||
lines.push(`${padName('Codex')}${statusLabel(codexStatus)}`);
|
||||
}
|
||||
|
||||
lines.push('```');
|
||||
|
||||
@@ -751,9 +795,13 @@ export async function startUnifiedDashboard(
|
||||
}
|
||||
|
||||
if (statusMessageId && channel.editMessage) {
|
||||
await channel.editMessage(statusJid, statusMessageId, content);
|
||||
await channel.editMessage(statusJid, statusMessageId, content, {
|
||||
rawMarkdown: true,
|
||||
});
|
||||
} else if (channel.sendAndTrack) {
|
||||
const id = await channel.sendAndTrack(statusJid, content);
|
||||
const id = await channel.sendAndTrack(statusJid, content, {
|
||||
rawMarkdown: true,
|
||||
});
|
||||
if (id) statusMessageId = id;
|
||||
}
|
||||
if (!dashboardUpdateLogged) {
|
||||
@@ -773,7 +821,29 @@ export async function startUnifiedDashboard(
|
||||
await updateStatus();
|
||||
|
||||
if (isRenderer) {
|
||||
setInterval(refreshUsageCache, RENDERER_USAGE_REFRESH_MS);
|
||||
const evaluatePairedMode = async () => {
|
||||
try {
|
||||
await evaluateAndApplyAutoPairedMode({
|
||||
queue: opts.queue,
|
||||
sendNotice: async (jid, text) => {
|
||||
const ch = opts.channels.find(
|
||||
(c) =>
|
||||
c.name.startsWith('discord') &&
|
||||
c.isConnected() &&
|
||||
c.ownsJid(jid),
|
||||
);
|
||||
if (ch) await ch.sendMessage(jid, text);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'auto-paired-mode evaluation failed');
|
||||
}
|
||||
};
|
||||
setInterval(async () => {
|
||||
await refreshUsageCache();
|
||||
await evaluatePairedMode();
|
||||
}, RENDERER_USAGE_REFRESH_MS);
|
||||
await evaluatePairedMode();
|
||||
}
|
||||
|
||||
// Codex usage collection — runs in unified service regardless of renderer role.
|
||||
|
||||
336
src/usage-exhaustion.test.ts
Normal file
336
src/usage-exhaustion.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Unit tests for the 95% usage-exhaustion helpers (Claude + Codex).
|
||||
*
|
||||
* These do not exercise the real disk cache or rotation state; they mock
|
||||
* just enough to verify the gate's truth-table:
|
||||
*
|
||||
* - no accounts → false (don't block)
|
||||
* - missing data on any → false (don't block)
|
||||
* - rate-limited account → counts as exhausted
|
||||
* - any account < 95% → false (route there instead)
|
||||
* - all accounts ≥ 95% → true, with earliest expected recovery time
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./logger.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/ejclaw-usage-exhaustion-data',
|
||||
}));
|
||||
|
||||
const { getAllTokensMock } = vi.hoisted(() => ({
|
||||
getAllTokensMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('./token-rotation.js', () => ({
|
||||
getAllTokens: getAllTokensMock,
|
||||
getCurrentToken: () => null,
|
||||
getConfiguredClaudeTokens: () => [],
|
||||
}));
|
||||
|
||||
const { getAllCodexAccountsMock } = vi.hoisted(() => ({
|
||||
getAllCodexAccountsMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('./codex-token-rotation.js', () => ({
|
||||
getAllCodexAccounts: getAllCodexAccountsMock,
|
||||
getCodexAuthPath: () => null,
|
||||
updateCodexAccountUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
const { readJsonFileMock } = vi.hoisted(() => ({
|
||||
readJsonFileMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('./utils.js', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./utils.js')>('./utils.js');
|
||||
return {
|
||||
...actual,
|
||||
readJsonFile: readJsonFileMock,
|
||||
writeJsonFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getClaudeUsageExhaustion', () => {
|
||||
beforeEach(() => {
|
||||
readJsonFileMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('returns exhausted=false when no tokens are configured', async () => {
|
||||
getAllTokensMock.mockReturnValue([]);
|
||||
const { getClaudeUsageExhaustion } = await import('./claude-usage.js');
|
||||
expect(getClaudeUsageExhaustion()).toEqual({
|
||||
exhausted: false,
|
||||
nextResetAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns exhausted=false when an account has no cached usage', async () => {
|
||||
getAllTokensMock.mockReturnValue([
|
||||
{
|
||||
token: 'sk-ant-oat01-aaaaaaaa',
|
||||
index: 0,
|
||||
masked: 'aaa',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
},
|
||||
]);
|
||||
readJsonFileMock.mockReturnValue({}); // empty cache
|
||||
const { getClaudeUsageExhaustion } = await import('./claude-usage.js');
|
||||
expect(getClaudeUsageExhaustion().exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it('returns exhausted=true with the earliest recovery when all accounts are exhausted', async () => {
|
||||
const reset0 = new Date('2026-04-28T01:00:00Z').toISOString();
|
||||
const reset1 = new Date('2026-04-28T03:00:00Z').toISOString();
|
||||
getAllTokensMock.mockReturnValue([
|
||||
{
|
||||
token: 'sk-ant-oat01-aaaaaaaa',
|
||||
index: 0,
|
||||
masked: 'aaa',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
},
|
||||
{
|
||||
token: 'sk-ant-oat01-bbbbbbbb',
|
||||
index: 1,
|
||||
masked: 'bbb',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
},
|
||||
]);
|
||||
readJsonFileMock.mockReturnValue({
|
||||
'account-0': {
|
||||
usage: {
|
||||
five_hour: { utilization: 96, resets_at: reset0 },
|
||||
seven_day: { utilization: 50, resets_at: '' },
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
'account-1': {
|
||||
usage: {
|
||||
five_hour: { utilization: 30, resets_at: '' },
|
||||
seven_day: { utilization: 99, resets_at: reset1 },
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const { getClaudeUsageExhaustion } = await import('./claude-usage.js');
|
||||
const info = getClaudeUsageExhaustion();
|
||||
expect(info.exhausted).toBe(true);
|
||||
// Earliest recovery is account-0 (5h reset at reset0).
|
||||
expect(info.nextResetAt).toBe(reset0);
|
||||
});
|
||||
|
||||
it('returns exhausted=false when at least one account still has headroom', async () => {
|
||||
getAllTokensMock.mockReturnValue([
|
||||
{
|
||||
token: 'sk-ant-oat01-aaaaaaaa',
|
||||
index: 0,
|
||||
masked: 'aaa',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
},
|
||||
{
|
||||
token: 'sk-ant-oat01-bbbbbbbb',
|
||||
index: 1,
|
||||
masked: 'bbb',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
},
|
||||
]);
|
||||
readJsonFileMock.mockReturnValue({
|
||||
'account-0': {
|
||||
usage: {
|
||||
five_hour: { utilization: 99 },
|
||||
seven_day: { utilization: 99 },
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
'account-1': {
|
||||
usage: {
|
||||
five_hour: { utilization: 80 },
|
||||
seven_day: { utilization: 70 },
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const { getClaudeUsageExhaustion } = await import('./claude-usage.js');
|
||||
expect(getClaudeUsageExhaustion().exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it('treats rate-limited accounts as exhausted', async () => {
|
||||
getAllTokensMock.mockReturnValue([
|
||||
{
|
||||
token: 'sk-ant-oat01-aaaaaaaa',
|
||||
index: 0,
|
||||
masked: 'aaa',
|
||||
isActive: false,
|
||||
isRateLimited: true,
|
||||
},
|
||||
]);
|
||||
readJsonFileMock.mockReturnValue({});
|
||||
const { getClaudeUsageExhaustion } = await import('./claude-usage.js');
|
||||
expect(getClaudeUsageExhaustion().exhausted).toBe(true);
|
||||
});
|
||||
|
||||
it('handles utilization on the 0–1 scale (legacy fractional form)', async () => {
|
||||
getAllTokensMock.mockReturnValue([
|
||||
{
|
||||
token: 'sk-ant-oat01-aaaaaaaa',
|
||||
index: 0,
|
||||
masked: 'aaa',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
},
|
||||
]);
|
||||
readJsonFileMock.mockReturnValue({
|
||||
'account-0': {
|
||||
usage: {
|
||||
five_hour: { utilization: 0.97 },
|
||||
seven_day: { utilization: 0.4 },
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const { getClaudeUsageExhaustion } = await import('./claude-usage.js');
|
||||
expect(getClaudeUsageExhaustion().exhausted).toBe(true);
|
||||
});
|
||||
|
||||
it('treats percent-scale utilization=1 as 1%, not 100%', async () => {
|
||||
getAllTokensMock.mockReturnValue([
|
||||
{
|
||||
token: 'sk-ant-oat01-aaaaaaaa',
|
||||
index: 0,
|
||||
masked: 'aaa',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
},
|
||||
]);
|
||||
readJsonFileMock.mockReturnValue({
|
||||
'account-0': {
|
||||
usage: {
|
||||
five_hour: { utilization: 1 },
|
||||
seven_day: { utilization: 40 },
|
||||
},
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const { getClaudeUsageExhaustion } = await import('./claude-usage.js');
|
||||
expect(getClaudeUsageExhaustion().exhausted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCodexUsageExhaustion', () => {
|
||||
it('returns exhausted=false when no Codex accounts are configured', async () => {
|
||||
getAllCodexAccountsMock.mockReturnValue([]);
|
||||
const { getCodexUsageExhaustion } =
|
||||
await import('./codex-usage-collector.js');
|
||||
expect(getCodexUsageExhaustion()).toEqual({
|
||||
exhausted: false,
|
||||
nextResetAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns exhausted=false when an account has unknown usage (-1)', async () => {
|
||||
getAllCodexAccountsMock.mockReturnValue([
|
||||
{
|
||||
index: 0,
|
||||
accountId: 'a',
|
||||
planType: 'pro',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: -1,
|
||||
cachedUsageD7Pct: -1,
|
||||
},
|
||||
]);
|
||||
const { getCodexUsageExhaustion } =
|
||||
await import('./codex-usage-collector.js');
|
||||
expect(getCodexUsageExhaustion().exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it('returns exhausted=true with earliest recovery when all accounts are exhausted', async () => {
|
||||
const reset0 = new Date('2026-04-28T02:00:00Z').toISOString();
|
||||
const reset1 = new Date('2026-04-28T05:00:00Z').toISOString();
|
||||
getAllCodexAccountsMock.mockReturnValue([
|
||||
{
|
||||
index: 0,
|
||||
accountId: 'a',
|
||||
planType: 'pro',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 96,
|
||||
cachedUsageD7Pct: 40,
|
||||
resetAt: reset0,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
accountId: 'b',
|
||||
planType: 'pro',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 30,
|
||||
cachedUsageD7Pct: 99,
|
||||
resetD7At: reset1,
|
||||
},
|
||||
]);
|
||||
const { getCodexUsageExhaustion } =
|
||||
await import('./codex-usage-collector.js');
|
||||
const info = getCodexUsageExhaustion();
|
||||
expect(info.exhausted).toBe(true);
|
||||
expect(info.nextResetAt).toBe(reset0);
|
||||
});
|
||||
|
||||
it('returns exhausted=false when at least one Codex account has headroom', async () => {
|
||||
getAllCodexAccountsMock.mockReturnValue([
|
||||
{
|
||||
index: 0,
|
||||
accountId: 'a',
|
||||
planType: 'pro',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 99,
|
||||
cachedUsageD7Pct: 99,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
accountId: 'b',
|
||||
planType: 'pro',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
cachedUsagePct: 50,
|
||||
cachedUsageD7Pct: 80,
|
||||
},
|
||||
]);
|
||||
const { getCodexUsageExhaustion } =
|
||||
await import('./codex-usage-collector.js');
|
||||
expect(getCodexUsageExhaustion().exhausted).toBe(false);
|
||||
});
|
||||
|
||||
it('treats rate-limited Codex accounts as exhausted', async () => {
|
||||
getAllCodexAccountsMock.mockReturnValue([
|
||||
{
|
||||
index: 0,
|
||||
accountId: 'a',
|
||||
planType: 'pro',
|
||||
isActive: false,
|
||||
isRateLimited: true,
|
||||
cachedUsagePct: 10,
|
||||
cachedUsageD7Pct: 10,
|
||||
},
|
||||
]);
|
||||
const { getCodexUsageExhaustion } =
|
||||
await import('./codex-usage-collector.js');
|
||||
expect(getCodexUsageExhaustion().exhausted).toBe(true);
|
||||
});
|
||||
});
|
||||
188
src/usage-primer.ts
Normal file
188
src/usage-primer.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { CODEX_WARMUP_CONFIG } from './config.js';
|
||||
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getAllTokens } from './token-rotation.js';
|
||||
|
||||
/**
|
||||
* Usage-window alignment primer.
|
||||
*
|
||||
* Anthropic/OpenAI enforce 5h rolling usage windows that start on first
|
||||
* inference call. We anchor windows to fixed KST times by firing a tiny
|
||||
* primer call at 08, 13, 18, 23 KST (4 windows × 5h = 20h, leaving a
|
||||
* 04:00–08:00 gap blocked by message-runtime-gating).
|
||||
*
|
||||
* Daily reset point ends up being 08:00 KST (start of first slot after gap).
|
||||
*/
|
||||
|
||||
const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const CLAUDE_PRIMER_TIMEOUT_MS = 60_000;
|
||||
|
||||
function resolveClaudeBinary(): string {
|
||||
const candidates = [
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude',
|
||||
),
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude',
|
||||
),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function runClaudePrimerOnce(
|
||||
token: string,
|
||||
): Promise<{ ok: boolean; reason: string }> {
|
||||
const binary = resolveClaudeBinary();
|
||||
if (!fs.existsSync(binary)) {
|
||||
return Promise.resolve({ ok: false, reason: 'binary_missing' });
|
||||
}
|
||||
const args = [
|
||||
'-p',
|
||||
'ok',
|
||||
'--model',
|
||||
'haiku',
|
||||
'--output-format',
|
||||
'text',
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--dangerously-skip-permissions',
|
||||
];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
let stderr = '';
|
||||
const finish = (result: { ok: boolean; reason: string }): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
finish({ ok: false, reason: 'timeout' });
|
||||
}, CLAUDE_PRIMER_TIMEOUT_MS);
|
||||
|
||||
const proc = spawn(binary, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CODE_OAUTH_TOKEN: token,
|
||||
},
|
||||
});
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 2000) stderr = stderr.slice(-2000);
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
logger.warn({ err }, 'Claude primer spawn error');
|
||||
finish({ ok: false, reason: 'spawn_error' });
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
finish({ ok: true, reason: 'ok' });
|
||||
} else {
|
||||
logger.warn(
|
||||
{ exitCode: code, stderr: stderr.trim().slice(-500) || undefined },
|
||||
'Claude primer command failed',
|
||||
);
|
||||
finish({ ok: false, reason: `exit_${code ?? 'unknown'}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runClaudePrimerCycle(): Promise<void> {
|
||||
const tokens = getAllTokens();
|
||||
const candidates = tokens.filter((t) => !t.isRateLimited);
|
||||
if (candidates.length === 0) {
|
||||
logger.info('Claude primer skipped: no eligible tokens');
|
||||
return;
|
||||
}
|
||||
const target = candidates[0];
|
||||
logger.info(
|
||||
{ tokenIndex: target.index },
|
||||
'Starting Claude primer (haiku, prompt=ok)',
|
||||
);
|
||||
const result = await runClaudePrimerOnce(target.token);
|
||||
if (result.ok) {
|
||||
logger.info({ tokenIndex: target.index }, 'Claude primer completed');
|
||||
} else {
|
||||
logger.warn(
|
||||
{ tokenIndex: target.index, reason: result.reason },
|
||||
'Claude primer failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCodexPrimerCycle(): Promise<void> {
|
||||
try {
|
||||
const result = await runCodexWarmupCycle(CODEX_WARMUP_CONFIG);
|
||||
logger.info({ result }, 'Codex primer cycle finished');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Codex primer cycle threw');
|
||||
}
|
||||
}
|
||||
|
||||
async function runPrimerCycle(): Promise<void> {
|
||||
logger.info('Running scheduled usage primer cycle');
|
||||
await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]);
|
||||
}
|
||||
|
||||
export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number {
|
||||
// KST = UTC+9 (no DST). Shift so KST clock can be read via getUTC*.
|
||||
const kstNowShifted = nowMs + KST_OFFSET_MS;
|
||||
const kstDate = new Date(kstNowShifted);
|
||||
const kstH = kstDate.getUTCHours();
|
||||
|
||||
let nextH = PRIMER_HOURS_KST.find((h) => h > kstH);
|
||||
let dayOffset = 0;
|
||||
if (nextH === undefined) {
|
||||
nextH = PRIMER_HOURS_KST[0];
|
||||
dayOffset = 1;
|
||||
}
|
||||
|
||||
const target = new Date(kstNowShifted);
|
||||
target.setUTCHours(nextH, 0, 0, 0);
|
||||
if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1);
|
||||
|
||||
return target.getTime() - kstNowShifted;
|
||||
}
|
||||
|
||||
let primerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function startUsagePrimer(): void {
|
||||
if (primerTimer) return;
|
||||
const scheduleNext = (): void => {
|
||||
const delay = msUntilNextPrimerSlotKST();
|
||||
primerTimer = setTimeout(() => {
|
||||
void runPrimerCycle().finally(scheduleNext);
|
||||
}, delay);
|
||||
const fireAt = new Date(Date.now() + delay).toISOString();
|
||||
logger.info(
|
||||
{ delayMinutes: Math.round(delay / 60_000), fireAt },
|
||||
'Next usage primer scheduled',
|
||||
);
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
export function stopUsagePrimer(): void {
|
||||
if (primerTimer) {
|
||||
clearTimeout(primerTimer);
|
||||
primerTimer = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user