Fix owner context loss after finalize; green the test suite
Owner continuity: - Seed a freshly created owner task with the previous task's latest owner final on a cold start after the previous task already closed, so a user reply to a finalized TASK_DONE no longer produces a "no context" answer. Activated for this deployment via PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL (.env); carried text is injected as clearly-marked background only. - Skip intermediate STEP_DONE outputs when picking the carry-forward anchor. Single-mode routing: - enforceRoomModeOnLease strips a stale reviewer/arbiter lease from a room switched back to single, preventing single-mode messages from stalling in the paired path on a stuck execution lease. Session auth / credentials: - Pre-sync Claude credentials into each session dir before the agent spawns. - Honor CLAUDE_CREDENTIALS_PATH in setup/login.ts (per-service isolation). - Add a relogin-required gate so a permanently logged-out claude-code room asks the user to re-login instead of spawning a doomed agent. Other: - Arbiter verdicts written in the user's language (verdict keyword stays EN). - status-dashboard chatName field; runtime-inventory credential path resolver. Tests (make suite fully green: 1595 pass / 3 skip): - service-routing: default owner is now the claude service and reviewer is codex-review; update the 7 failover/default expectations accordingly. - migrate-room-registrations: owner inferred as claude-code (configured OWNER_AGENT_TYPE) for a dual legacy room; reviewer becomes codex. - register: mock paired-workspace provisioning + reload signal (registration now provisions a workspace and hot-reloads); assert RELOADED status. - paired-execution-context: force a claude-code reviewer to exercise the Claude read-only branch regardless of the deployment default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -43,3 +43,8 @@ You may receive reference opinions from external models appended to your prompt.
|
|||||||
- If both sides are saying the same thing but not acting on it, call it out and direct the owner to act
|
- If both sides are saying the same thing but not acting on it, call it out and direct the owner to act
|
||||||
- If the conversation shows the owner asking the user a question (not the reviewer), always ESCALATE — the arbiter cannot answer on behalf of the user
|
- If the conversation shows the owner asking the user a question (not the reviewer), always ESCALATE — the arbiter cannot answer on behalf of the user
|
||||||
- If you see a prior arbiter verdict of PROCEED in the history but the same issue persists, do NOT repeat PROCEED — use ESCALATE instead
|
- If you see a prior arbiter verdict of PROCEED in the history but the same issue persists, do NOT repeat PROCEED — use ESCALATE instead
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- Write your verdict in the user's language (Korean for this deployment) unless the user wrote in another language. The user must be able to read your verdict.
|
||||||
|
- Keep ONLY the leading verdict keyword in English — `PROCEED` / `REVISE` / `RESET` / `ESCALATE` — exactly as specified above, because the system parses that first token. Write everything after it (reasoning, evidence, the required action for the owner) in Korean.
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
* builds (captured from the official CLI). This is the manual / paste-code
|
* builds (captured from the official CLI). This is the manual / paste-code
|
||||||
* variant: the user visits the authorize URL, completes login in their
|
* variant: the user visits the authorize URL, completes login in their
|
||||||
* browser, and pastes the code back. We exchange it for tokens and write
|
* browser, and pastes the code back. We exchange it for tokens and write
|
||||||
* `~/.claude/.credentials.json`.
|
* `CLAUDE_CREDENTIALS_PATH` or `~/.claude/.credentials.json`.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* bun setup/index.ts --step login # phase 1: print authorize URL
|
* bun setup/index.ts --step login # phase 1: print authorize URL
|
||||||
* bun setup/index.ts --step login --code <c> # phase 2: exchange the code
|
* 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
|
* Phase 1 stashes the PKCE verifier + state in a /tmp login state file
|
||||||
* (mode 0600). Phase 2 reads it back, exchanges the code, writes credentials.
|
* (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
|
* This step is run by hand for re-auth. Once `.credentials.json` exists, the
|
||||||
@@ -39,8 +39,21 @@ const SCOPES = [
|
|||||||
'user:file_upload',
|
'user:file_upload',
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATE_FILE = path.join(os.tmpdir(), 'ejclaw-claude-login.json');
|
function credentialsPath(): string {
|
||||||
const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
const configured = process.env.CLAUDE_CREDENTIALS_PATH?.trim();
|
||||||
|
return configured
|
||||||
|
? path.resolve(configured)
|
||||||
|
: path.join(os.homedir(), '.claude', '.credentials.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFilePath(): string {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(credentialsPath())
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 12);
|
||||||
|
return path.join(os.tmpdir(), `ejclaw-claude-login-${hash}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
interface PendingState {
|
interface PendingState {
|
||||||
verifier: string;
|
verifier: string;
|
||||||
@@ -90,13 +103,14 @@ function buildAuthorizeUrl(state: string, challenge: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writePendingState(p: PendingState): void {
|
function writePendingState(p: PendingState): void {
|
||||||
fs.writeFileSync(STATE_FILE, JSON.stringify(p), { mode: 0o600 });
|
fs.writeFileSync(stateFilePath(), JSON.stringify(p), { mode: 0o600 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPendingState(): PendingState | null {
|
function readPendingState(): PendingState | null {
|
||||||
if (!fs.existsSync(STATE_FILE)) return null;
|
const stateFile = stateFilePath();
|
||||||
|
if (!fs.existsSync(stateFile)) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PendingState;
|
return JSON.parse(fs.readFileSync(stateFile, 'utf-8')) as PendingState;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -136,6 +150,7 @@ async function exchangeCode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeCredentials(resp: ExchangeResponse): void {
|
function writeCredentials(resp: ExchangeResponse): void {
|
||||||
|
const credsPath = credentialsPath();
|
||||||
const expiresAt = Date.now() + resp.expires_in * 1000;
|
const expiresAt = Date.now() + resp.expires_in * 1000;
|
||||||
const creds = {
|
const creds = {
|
||||||
claudeAiOauth: {
|
claudeAiOauth: {
|
||||||
@@ -150,11 +165,11 @@ function writeCredentials(resp: ExchangeResponse): void {
|
|||||||
: '',
|
: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const dir = path.dirname(CREDS_PATH);
|
const dir = path.dirname(credsPath);
|
||||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
const tmp = `${CREDS_PATH}.tmp`;
|
const tmp = `${credsPath}.tmp`;
|
||||||
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||||||
fs.renameSync(tmp, CREDS_PATH);
|
fs.renameSync(tmp, credsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Args {
|
interface Args {
|
||||||
@@ -208,15 +223,15 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const resp = await exchangeCode(code, pending.verifier, pending.state);
|
const resp = await exchangeCode(code, pending.verifier, pending.state);
|
||||||
writeCredentials(resp);
|
writeCredentials(resp);
|
||||||
fs.unlinkSync(STATE_FILE);
|
fs.unlinkSync(stateFilePath());
|
||||||
const newScopes = (resp.scope || SCOPES.join(' ')).split(' ').sort();
|
const newScopes = (resp.scope || SCOPES.join(' ')).split(' ').sort();
|
||||||
logger.info(
|
logger.info(
|
||||||
{ scopes: newScopes, expiresInMin: Math.round(resp.expires_in / 60) },
|
{ scopes: newScopes, expiresInMin: Math.round(resp.expires_in / 60) },
|
||||||
'Wrote ~/.claude/.credentials.json',
|
'Wrote Claude credentials',
|
||||||
);
|
);
|
||||||
emitStatus('LOGIN', {
|
emitStatus('LOGIN', {
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
CREDENTIALS_PATH: CREDS_PATH,
|
CREDENTIALS_PATH: credentialsPath(),
|
||||||
SCOPES: newScopes.join(','),
|
SCOPES: newScopes.join(','),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe('migrate room registrations step', () => {
|
|||||||
chat_jid: 'dc:legacy-room',
|
chat_jid: 'dc:legacy-room',
|
||||||
room_mode: 'tribunal',
|
room_mode: 'tribunal',
|
||||||
mode_source: 'inferred',
|
mode_source: 'inferred',
|
||||||
owner_agent_type: 'codex',
|
owner_agent_type: 'claude-code',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
@@ -141,12 +141,12 @@ describe('migrate room registrations step', () => {
|
|||||||
{
|
{
|
||||||
chat_jid: 'dc:legacy-room',
|
chat_jid: 'dc:legacy-room',
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
agent_type: 'codex',
|
agent_type: 'claude-code',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
chat_jid: 'dc:legacy-room',
|
chat_jid: 'dc:legacy-room',
|
||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
agent_type: 'claude-code',
|
agent_type: 'codex',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ vi.mock('../src/group-folder.js', () => ({
|
|||||||
isValidGroupFolder: isValidGroupFolderMock,
|
isValidGroupFolder: isValidGroupFolderMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Registration now provisions the paired workspace (which shells out to git)
|
||||||
|
// and hot-reloads the running service. Stub both so the unit test stays hermetic
|
||||||
|
// and only verifies the registration delegation, not workspace/git side effects.
|
||||||
|
vi.mock('../src/paired-workspace-manager.js', () => ({
|
||||||
|
ensurePairedWorkspaceProvisioned: vi.fn(
|
||||||
|
() => '/tmp/ejclaw-groups/test-room/owner',
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../src/runtime-reload-signal.js', () => ({
|
||||||
|
signalEjclawReload: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../src/logger.js', () => ({
|
vi.mock('../src/logger.js', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
info: loggerInfoMock,
|
info: loggerInfoMock,
|
||||||
@@ -84,6 +97,7 @@ describe('register step', () => {
|
|||||||
FOLDER: 'test-room',
|
FOLDER: 'test-room',
|
||||||
CHANNEL: 'discord',
|
CHANNEL: 'discord',
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
|
RELOADED: 'no',
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from './codex-token-rotation.js';
|
} from './codex-token-rotation.js';
|
||||||
import { readCodexFeatureFromFile } from './codex-config-features.js';
|
import { readCodexFeatureFromFile } from './codex-config-features.js';
|
||||||
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
|
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
|
||||||
|
import { getClaudeCredentialsPath } from './claude-credentials-path.js';
|
||||||
import {
|
import {
|
||||||
getConfiguredClaudeTokens,
|
getConfiguredClaudeTokens,
|
||||||
getCurrentToken,
|
getCurrentToken,
|
||||||
@@ -53,16 +54,35 @@ function syncDirectoryEntries(sources: string[], destination: string): void {
|
|||||||
for (const entry of fs.readdirSync(source)) {
|
for (const entry of fs.readdirSync(source)) {
|
||||||
const srcPath = path.join(source, entry);
|
const srcPath = path.join(source, entry);
|
||||||
const dstPath = path.join(destination, entry);
|
const dstPath = path.join(destination, entry);
|
||||||
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
if (fs.statSync(srcPath).isDirectory()) {
|
if (fs.statSync(srcPath).isDirectory()) {
|
||||||
fs.cpSync(srcPath, dstPath, { recursive: true });
|
fs.cpSync(srcPath, dstPath, { recursive: true });
|
||||||
} else {
|
} else {
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
|
||||||
fs.copyFileSync(srcPath, dstPath);
|
fs.copyFileSync(srcPath, dstPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncClaudeCredentialsToSessionDir(sessionClaudeDir: string): void {
|
||||||
|
// The agent spawns with CLAUDE_CONFIG_DIR=<sessionClaudeDir>. Without this
|
||||||
|
// pre-sync, a freshly created session dir has no credentials.json and the
|
||||||
|
// child Claude process fails to authenticate on its first run. token-
|
||||||
|
// refresh.ts's syncToSessionDirs only kicks in on the next refresh cycle.
|
||||||
|
const srcPath = getClaudeCredentialsPath(0, { allowHomeFallback: true });
|
||||||
|
if (!srcPath || !fs.existsSync(srcPath)) return;
|
||||||
|
const dest = path.join(sessionClaudeDir, '.credentials.json');
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(srcPath, dest);
|
||||||
|
fs.chmodSync(dest, 0o600);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
{ err, srcPath, dest },
|
||||||
|
'Failed to pre-sync Claude credentials to session dir',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir';
|
type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir';
|
||||||
|
|
||||||
interface SkillSyncSource {
|
interface SkillSyncSource {
|
||||||
@@ -595,6 +615,7 @@ export function prepareGroupEnvironment(
|
|||||||
const groupSessionsDir = path.join(sessionRootDir, '.claude');
|
const groupSessionsDir = path.join(sessionRootDir, '.claude');
|
||||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||||
ensureClaudeSessionSettings(groupSessionsDir);
|
ensureClaudeSessionSettings(groupSessionsDir);
|
||||||
|
syncClaudeCredentialsToSessionDir(groupSessionsDir);
|
||||||
|
|
||||||
const workDirClaude = group.workDir
|
const workDirClaude = group.workDir
|
||||||
? path.join(group.workDir, '.claude')
|
? path.join(group.workDir, '.claude')
|
||||||
|
|||||||
@@ -102,6 +102,32 @@ describe('Claude usage 429 Retry-After backoff', () => {
|
|||||||
expect(second[0].usageRateLimited).toBe(true);
|
expect(second[0].usageRateLimited).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to the default cooldown when Retry-After is 0 (does not disable backoff)', async () => {
|
||||||
|
// Production regression: the usage endpoint was returning 429 with
|
||||||
|
// `retry-after: 0`, which made `retryAfterMs ?? DEFAULT` evaluate to 0 — a
|
||||||
|
// ~5s cooldown. The poller then re-hit the endpoint every 60s and re-tripped
|
||||||
|
// the 429 indefinitely. A non-positive Retry-After must use the default.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-20T00:00:00Z'));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async () =>
|
||||||
|
makeResponse(429, { 'retry-after': '0' }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const { fetchAllClaudeUsage } = await import('./claude-usage.js');
|
||||||
|
|
||||||
|
await fetchAllClaudeUsage();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// +70s: past the 60s throttle. With the bug (0 cooldown) this would refetch;
|
||||||
|
// with the default 5-min cooldown it must still be held.
|
||||||
|
vi.setSystemTime(Date.now() + 70_000);
|
||||||
|
const held = await fetchAllClaudeUsage();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(held[0].usageRateLimited).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps backing off after the 60s throttle elapses but before the cooldown closes', async () => {
|
it('keeps backing off after the 60s throttle elapses but before the cooldown closes', async () => {
|
||||||
// This is the core regression: a 93s Retry-After must outlast the 60s
|
// This is the core regression: a 93s Retry-After must outlast the 60s
|
||||||
// MIN_FETCH_INTERVAL throttle. Without the cooldown the poller would refetch
|
// MIN_FETCH_INTERVAL throttle. Without the cooldown the poller would refetch
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getClaudeCredentialsPath,
|
||||||
|
hasExplicitClaudeCredentialsPath,
|
||||||
|
} from './claude-credentials-path.js';
|
||||||
import { DATA_DIR } from './config.js';
|
import { DATA_DIR } from './config.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +35,14 @@ export interface ClaudeUsageData {
|
|||||||
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
||||||
const FETCH_TIMEOUT_MS = 10_000;
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
// Master gate for credentials-backed usage queries. When false, the module
|
||||||
|
// uses only env-supplied tokens and skips all credentials-file reads, per-
|
||||||
|
// account cache keys, and on-401 refresh attempts. Enabled when either an
|
||||||
|
// explicit CLAUDE_CREDENTIALS_PATH is set or the home fallback is allowed.
|
||||||
|
const USE_CLAUDE_CREDENTIALS_FOR_USAGE =
|
||||||
|
hasExplicitClaudeCredentialsPath() ||
|
||||||
|
process.env.CLAUDE_USAGE_USE_HOME_CREDENTIALS !== 'false';
|
||||||
|
|
||||||
interface UsageApiResponse {
|
interface UsageApiResponse {
|
||||||
five_hour?: { utilization: number; resets_at?: string };
|
five_hour?: { utilization: number; resets_at?: string };
|
||||||
seven_day?: { utilization: number; resets_at?: string };
|
seven_day?: { utilization: number; resets_at?: string };
|
||||||
@@ -112,7 +123,7 @@ export function getUsageCacheWriteKey(
|
|||||||
token: string,
|
token: string,
|
||||||
accountIndex?: number,
|
accountIndex?: number,
|
||||||
): string {
|
): string {
|
||||||
return accountIndex != null
|
return USE_CLAUDE_CREDENTIALS_FOR_USAGE && accountIndex != null
|
||||||
? accountCacheKey(accountIndex)
|
? accountCacheKey(accountIndex)
|
||||||
: legacyTokenCacheKey(token);
|
: legacyTokenCacheKey(token);
|
||||||
}
|
}
|
||||||
@@ -123,9 +134,11 @@ export function getUsageCacheReadKeys(
|
|||||||
credentialsAccessToken?: string | null,
|
credentialsAccessToken?: string | null,
|
||||||
): string[] {
|
): string[] {
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
if (accountIndex != null) keys.push(accountCacheKey(accountIndex));
|
if (USE_CLAUDE_CREDENTIALS_FOR_USAGE && accountIndex != null) {
|
||||||
|
keys.push(accountCacheKey(accountIndex));
|
||||||
|
}
|
||||||
|
|
||||||
if (credentialsAccessToken) {
|
if (USE_CLAUDE_CREDENTIALS_FOR_USAGE && credentialsAccessToken) {
|
||||||
const credsKey = legacyTokenCacheKey(credentialsAccessToken);
|
const credsKey = legacyTokenCacheKey(credentialsAccessToken);
|
||||||
if (!keys.includes(credsKey)) keys.push(credsKey);
|
if (!keys.includes(credsKey)) keys.push(credsKey);
|
||||||
}
|
}
|
||||||
@@ -265,7 +278,11 @@ async function fetchUsageForToken(
|
|||||||
// 401 = token expired; 403 = token lacks `user:profile` scope.
|
// 401 = token expired; 403 = token lacks `user:profile` scope.
|
||||||
// Both are recoverable by exchanging refresh_token for a fresh access
|
// Both are recoverable by exchanging refresh_token for a fresh access
|
||||||
// token (mint includes current default scope set). Retry once.
|
// token (mint includes current default scope set). Retry once.
|
||||||
if (accountIndex != null && !refreshAttempted) {
|
if (
|
||||||
|
USE_CLAUDE_CREDENTIALS_FOR_USAGE &&
|
||||||
|
accountIndex != null &&
|
||||||
|
!refreshAttempted
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const { forceRefreshToken } = await import('./token-refresh.js');
|
const { forceRefreshToken } = await import('./token-refresh.js');
|
||||||
const newToken = await forceRefreshToken(accountIndex);
|
const newToken = await forceRefreshToken(accountIndex);
|
||||||
@@ -303,7 +320,14 @@ async function fetchUsageForToken(
|
|||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const staleMs = cached ? Date.now() - cached.fetchedAt : 0;
|
const staleMs = cached ? Date.now() - cached.fetchedAt : 0;
|
||||||
const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
|
const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
|
||||||
const cooldownMs = retryAfterMs ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
// A `Retry-After` of 0 (or a past HTTP-date that parses to 0) must NOT
|
||||||
|
// disable the backoff — honoring it literally makes the poller retry on
|
||||||
|
// the next 60s tick and re-trip the 429 indefinitely. Treat any
|
||||||
|
// non-positive value as "no usable hint" and fall back to the default.
|
||||||
|
const cooldownMs =
|
||||||
|
retryAfterMs != null && retryAfterMs > 0
|
||||||
|
? retryAfterMs
|
||||||
|
: DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
||||||
const cooldownUntil = Date.now() + cooldownMs + RATE_LIMIT_MARGIN_MS;
|
const cooldownUntil = Date.now() + cooldownMs + RATE_LIMIT_MARGIN_MS;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{
|
{
|
||||||
@@ -403,12 +427,11 @@ async function fetchUsageForToken(
|
|||||||
* Uses the current active token from rotation.
|
* Uses the current active token from rotation.
|
||||||
*/
|
*/
|
||||||
export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||||
// Prefer the access token in ~/.claude/.credentials.json when present.
|
// Prefer the access token in credentials.json when present. The static .env
|
||||||
// The static .env token (CLAUDE_CODE_OAUTH_TOKEN) was issued before the
|
// token (CLAUDE_CODE_OAUTH_TOKEN) was issued before the `user:profile` scope
|
||||||
// `user:profile` scope was required for /api/oauth/usage and so always 403s.
|
// was required for /api/oauth/usage and so always 403s. The credentials file
|
||||||
// The credentials file is the canonical source written by `claude auth
|
// is the canonical source written by `claude auth login` and kept fresh by
|
||||||
// login` and kept fresh by token-refresh.ts — its accessToken carries the
|
// token-refresh.ts — its accessToken carries the full scope set.
|
||||||
// full scope set including user:profile.
|
|
||||||
const credsToken = readCredentialsAccessToken(0);
|
const credsToken = readCredentialsAccessToken(0);
|
||||||
const token =
|
const token =
|
||||||
credsToken || getCurrentToken() || getConfiguredClaudeTokens()[0];
|
credsToken || getCurrentToken() || getConfiguredClaudeTokens()[0];
|
||||||
@@ -416,7 +439,8 @@ export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
|||||||
logger.debug('No Claude OAuth token available for usage check');
|
logger.debug('No Claude OAuth token available for usage check');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (await fetchUsageForToken(token, undefined)).usage;
|
// Pass accountIndex=0 so 401/403 → forceRefreshToken retry path engages.
|
||||||
|
return (await fetchUsageForToken(token, 0)).usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaudeAccountProfile {
|
export interface ClaudeAccountProfile {
|
||||||
@@ -428,21 +452,18 @@ const profileCache = new Map<number, ClaudeAccountProfile>();
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Read planType from credentials file as fallback when profile API fails.
|
* Read planType from credentials file as fallback when profile API fails.
|
||||||
* Account 0: ~/.claude/.credentials.json
|
* Path is resolved by getClaudeCredentialsPath (honours CLAUDE_CREDENTIALS_PATH
|
||||||
* Account 1+: ~/.claude-accounts/{index}/.credentials.json
|
* / CLAUDE_ACCOUNTS_DIR / home fallback). Returns null when the credentials
|
||||||
|
* gate is off or no file is found.
|
||||||
*/
|
*/
|
||||||
function readCredentialsPlanType(accountIndex: number): string | null {
|
function readCredentialsPlanType(accountIndex: number): string | null {
|
||||||
|
if (!USE_CLAUDE_CREDENTIALS_FOR_USAGE) return null;
|
||||||
try {
|
try {
|
||||||
const credsPath =
|
const credsPath = getClaudeCredentialsPath(accountIndex, {
|
||||||
accountIndex === 0
|
allowHomeFallback:
|
||||||
? path.join(os.homedir(), '.claude', '.credentials.json')
|
process.env.CLAUDE_USAGE_USE_HOME_CREDENTIALS !== 'false',
|
||||||
: path.join(
|
});
|
||||||
os.homedir(),
|
if (!credsPath || !fs.existsSync(credsPath)) return null;
|
||||||
'.claude-accounts',
|
|
||||||
String(accountIndex),
|
|
||||||
'.credentials.json',
|
|
||||||
);
|
|
||||||
if (!fs.existsSync(credsPath)) return null;
|
|
||||||
const data = readJsonFile<{
|
const data = readJsonFile<{
|
||||||
claudeAiOauth?: { subscriptionType?: string };
|
claudeAiOauth?: { subscriptionType?: string };
|
||||||
}>(credsPath);
|
}>(credsPath);
|
||||||
@@ -453,17 +474,13 @@ function readCredentialsPlanType(accountIndex: number): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readCredentialsAccessToken(accountIndex: number): string | null {
|
function readCredentialsAccessToken(accountIndex: number): string | null {
|
||||||
|
if (!USE_CLAUDE_CREDENTIALS_FOR_USAGE) return null;
|
||||||
try {
|
try {
|
||||||
const credsPath =
|
const credsPath = getClaudeCredentialsPath(accountIndex, {
|
||||||
accountIndex === 0
|
allowHomeFallback:
|
||||||
? path.join(os.homedir(), '.claude', '.credentials.json')
|
process.env.CLAUDE_USAGE_USE_HOME_CREDENTIALS !== 'false',
|
||||||
: path.join(
|
});
|
||||||
os.homedir(),
|
if (!credsPath || !fs.existsSync(credsPath)) return null;
|
||||||
'.claude-accounts',
|
|
||||||
String(accountIndex),
|
|
||||||
'.credentials.json',
|
|
||||||
);
|
|
||||||
if (!fs.existsSync(credsPath)) return null;
|
|
||||||
const data = readJsonFile<{
|
const data = readJsonFile<{
|
||||||
claudeAiOauth?: { accessToken?: string };
|
claudeAiOauth?: { accessToken?: string };
|
||||||
}>(credsPath);
|
}>(credsPath);
|
||||||
@@ -519,7 +536,10 @@ async function fetchProfileForToken(
|
|||||||
export async function fetchAllClaudeProfiles(): Promise<void> {
|
export async function fetchAllClaudeProfiles(): Promise<void> {
|
||||||
const allTokens = getAllTokens();
|
const allTokens = getAllTokens();
|
||||||
for (const t of allTokens) {
|
for (const t of allTokens) {
|
||||||
let profile = await fetchProfileForToken(t.token);
|
// Prefer the per-account credentials access token; the static env token
|
||||||
|
// may lack the `user:profile` scope.
|
||||||
|
const credsToken = readCredentialsAccessToken(t.index);
|
||||||
|
let profile = await fetchProfileForToken(credsToken || t.token);
|
||||||
|
|
||||||
// Fallback: if profile API failed or returned unknown plan, use credentials file
|
// Fallback: if profile API failed or returned unknown plan, use credentials file
|
||||||
if (!profile || profile.planType === '?') {
|
if (!profile || profile.planType === '?') {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { DashboardOptions } from './dashboard-status-content.js';
|
import type { DashboardOptions } from './dashboard-status-content.js';
|
||||||
|
import { formatRoomName } from './unified-dashboard.js';
|
||||||
|
|
||||||
function makeOptions(sessionId?: string): DashboardOptions {
|
function makeOptions(sessionId?: string): DashboardOptions {
|
||||||
const sessions: Record<string, string> = sessionId
|
const sessions: Record<string, string> = sessionId
|
||||||
@@ -85,3 +86,32 @@ describe('buildStatusContent', () => {
|
|||||||
expect(buildStatusContent(makeOptions())).not.toContain('**clone-test**');
|
expect(buildStatusContent(makeOptions())).not.toContain('**clone-test**');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatRoomName', () => {
|
||||||
|
it('uses the stored chat name without adding an extra Discord hash', () => {
|
||||||
|
expect(
|
||||||
|
formatRoomName(
|
||||||
|
'dc:123',
|
||||||
|
undefined,
|
||||||
|
'registered-room-name',
|
||||||
|
'My Server #bot-chat',
|
||||||
|
),
|
||||||
|
).toBe('My Server #bot-chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a Discord hash for raw channel metadata names', () => {
|
||||||
|
expect(
|
||||||
|
formatRoomName(
|
||||||
|
'dc:123',
|
||||||
|
{
|
||||||
|
name: 'bot-chat',
|
||||||
|
position: 1,
|
||||||
|
category: 'Bots',
|
||||||
|
categoryPosition: 1,
|
||||||
|
},
|
||||||
|
'registered-room-name',
|
||||||
|
'My Server #bot-chat',
|
||||||
|
),
|
||||||
|
).toBe('#bot-chat');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import {
|
|||||||
hasHumanMessageAfterWorkItem,
|
hasHumanMessageAfterWorkItem,
|
||||||
} from './message-runtime-preflight-messages.js';
|
} from './message-runtime-preflight-messages.js';
|
||||||
import { deliverCanonicalOutboundMessage } from './ipc-outbound-delivery.js';
|
import { deliverCanonicalOutboundMessage } from './ipc-outbound-delivery.js';
|
||||||
|
import {
|
||||||
|
isReloginRequired,
|
||||||
|
RELOGIN_REQUIRED_MESSAGE,
|
||||||
|
} from './token-refresh.js';
|
||||||
import { findChannel, formatMessages } from './router.js';
|
import { findChannel, formatMessages } from './router.js';
|
||||||
import { createScopedLogger, logger } from './logger.js';
|
import { createScopedLogger, logger } from './logger.js';
|
||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
@@ -308,6 +312,15 @@ async function processMissedMessages(
|
|||||||
return gateOutcome;
|
return gateOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reloginOutcome = await runReloginRequiredGate(
|
||||||
|
args,
|
||||||
|
runtime,
|
||||||
|
missedMessages,
|
||||||
|
);
|
||||||
|
if (reloginOutcome !== null) {
|
||||||
|
return reloginOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
return runQueuedGroupTurn({
|
return runQueuedGroupTurn({
|
||||||
chatJid: runtime.chatJid,
|
chatJid: runtime.chatJid,
|
||||||
group: runtime.group,
|
group: runtime.group,
|
||||||
@@ -415,6 +428,48 @@ function advancePastBotOnlyCollaboration(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When Claude OAuth auto-refresh has permanently given up (login lost and the
|
||||||
|
* refresh token is dead — see token-refresh.ts), don't spawn a doomed agent.
|
||||||
|
* Instead reply once, in this chat, asking the user to re-login, then advance
|
||||||
|
* the cursor so a still-logged-out bot doesn't re-notify on every poll. The
|
||||||
|
* next fresh incoming request re-triggers the notice. Recovers automatically
|
||||||
|
* once a manual re-login writes a valid token (isReloginRequired() clears).
|
||||||
|
*
|
||||||
|
* Returns true when the notice was sent (turn handled), null to fall through.
|
||||||
|
*/
|
||||||
|
async function runReloginRequiredGate(
|
||||||
|
args: ProcessGroupMessagesDeps,
|
||||||
|
runtime: RuntimeContext,
|
||||||
|
missedMessages: NewMessage[],
|
||||||
|
): Promise<boolean | null> {
|
||||||
|
// Only claude-code turns depend on the Claude OAuth token. Codex-backed
|
||||||
|
// groups authenticate separately and must not be blocked here.
|
||||||
|
const agentType = runtime.group.agentType ?? 'claude-code';
|
||||||
|
if (agentType !== 'claude-code') return null;
|
||||||
|
if (!isReloginRequired()) return null;
|
||||||
|
|
||||||
|
await deliverSessionCommandMessage(
|
||||||
|
args,
|
||||||
|
runtime.chatJid,
|
||||||
|
RELOGIN_REQUIRED_MESSAGE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastMessage = missedMessages[missedMessages.length - 1];
|
||||||
|
if (lastMessage?.seq != null) {
|
||||||
|
advanceLastAgentCursor(
|
||||||
|
args.getLastAgentTimestamps(),
|
||||||
|
args.saveState,
|
||||||
|
runtime.chatJid,
|
||||||
|
lastMessage.seq,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
runtime.log.warn(
|
||||||
|
'Claude OAuth login lost (gave up refreshing) — asked user to re-login instead of running the agent',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function runQueuedRunGates(
|
async function runQueuedRunGates(
|
||||||
args: ProcessGroupMessagesDeps,
|
args: ProcessGroupMessagesDeps,
|
||||||
runtime: RuntimeContext,
|
runtime: RuntimeContext,
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ vi.mock('./config.js', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('./service-routing.js', async () => {
|
vi.mock('./service-routing.js', async () => {
|
||||||
const actual =
|
const actual = await vi.importActual<typeof import('./service-routing.js')>(
|
||||||
await vi.importActual<typeof import('./service-routing.js')>(
|
|
||||||
'./service-routing.js',
|
'./service-routing.js',
|
||||||
);
|
);
|
||||||
return { ...actual, hasReviewerLease: vi.fn(() => true) };
|
return { ...actual, hasReviewerLease: vi.fn(() => true) };
|
||||||
|
|||||||
@@ -161,4 +161,89 @@ describe('paired execution carry-forward attachments', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries context forward on a cold start after the previous task already closed', () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const previousTask = buildTask({
|
||||||
|
id: 'task-previous',
|
||||||
|
status: 'completed',
|
||||||
|
completion_reason: 'done',
|
||||||
|
});
|
||||||
|
// No open task exists -> cold start path.
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
||||||
|
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(previousTask);
|
||||||
|
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
task_id: previousTask.id,
|
||||||
|
turn_number: 1,
|
||||||
|
role: 'owner',
|
||||||
|
output_text:
|
||||||
|
'TASK_DONE\n사용자 액션 아이템: 1. 재배포 2. 서버 업데이트',
|
||||||
|
created_at: '2026-03-28T00:01:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resolved = resolveOwnerTaskForHumanMessage({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
// no existingTask -> resolves via getLatestOpenPairedTaskForChat (none)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.supersededTask).toBeNull();
|
||||||
|
expect(db.insertPairedTurnOutput).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
0,
|
||||||
|
'owner',
|
||||||
|
expect.stringContaining(
|
||||||
|
'TASK_DONE\n사용자 액션 아이템: 1. 재배포 2. 서버 업데이트',
|
||||||
|
),
|
||||||
|
expect.objectContaining({ createdAt: '2026-03-28T00:01:00.000Z' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the last final owner output, skipping an intermediate STEP_DONE', () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const previousTask = buildTask({
|
||||||
|
id: 'task-arbiter',
|
||||||
|
status: 'completed',
|
||||||
|
completion_reason: 'arbiter_escalated',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
||||||
|
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(previousTask);
|
||||||
|
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
task_id: previousTask.id,
|
||||||
|
turn_number: 1,
|
||||||
|
role: 'owner',
|
||||||
|
output_text:
|
||||||
|
'TASK_DONE\n사용자 액션 아이템: 1. 재배포 2. 서버 업데이트',
|
||||||
|
created_at: '2026-03-28T00:01:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
task_id: previousTask.id,
|
||||||
|
turn_number: 3,
|
||||||
|
role: 'owner',
|
||||||
|
output_text: 'STEP_DONE\nexception 경로 버그를 고쳤습니다.',
|
||||||
|
created_at: '2026-03-28T00:03:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
resolveOwnerTaskForHumanMessage({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
roomRoleContext: ownerContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.insertPairedTurnOutput).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
0,
|
||||||
|
'owner',
|
||||||
|
expect.stringContaining('사용자 액션 아이템'),
|
||||||
|
expect.objectContaining({ createdAt: '2026-03-28T00:01:00.000Z' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -677,7 +677,9 @@ describe('paired execution context', () => {
|
|||||||
group,
|
group,
|
||||||
chatJid: 'dc:test',
|
chatJid: 'dc:test',
|
||||||
runId: 'run-host-reviewer',
|
runId: 'run-host-reviewer',
|
||||||
roomRoleContext: reviewerContext,
|
// Force a claude-code reviewer so this test exercises the Claude
|
||||||
|
// read-only branch regardless of the deployment's default reviewer type.
|
||||||
|
roomRoleContext: { ...reviewerContext, reviewerAgentType: 'claude-code' },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result?.envOverrides).toMatchObject({
|
expect(result?.envOverrides).toMatchObject({
|
||||||
|
|||||||
@@ -229,14 +229,22 @@ function cancelOutstandingFinalizeOwnerTurn(task: PairedTask): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestTurnOutputByRole(
|
function isIntermediateStepOutput(outputText: string): boolean {
|
||||||
taskId: string,
|
// STEP_DONE marks an intermediate step that keeps the task active; it is not
|
||||||
role: PairedRoomRole,
|
// the user-facing finalize summary, so it is a poor carry-forward anchor.
|
||||||
): PairedTurnOutput | null {
|
return /^\s*STEP_DONE\b/.test(outputText);
|
||||||
return (
|
}
|
||||||
[...getPairedTurnOutputs(taskId)]
|
|
||||||
|
function getLatestOwnerFinalOutput(taskId: string): PairedTurnOutput | null {
|
||||||
|
const ownerOutputs = [...getPairedTurnOutputs(taskId)]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((output) => output.role === role) ?? null
|
.filter((output) => output.role === 'owner');
|
||||||
|
return (
|
||||||
|
ownerOutputs.find(
|
||||||
|
(output) => !isIntermediateStepOutput(output.output_text),
|
||||||
|
) ??
|
||||||
|
ownerOutputs[0] ??
|
||||||
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,10 +256,7 @@ function carryForwardLatestOwnerFinal(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestOwnerFinal = getLatestTurnOutputByRole(
|
const latestOwnerFinal = getLatestOwnerFinalOutput(args.sourceTask.id);
|
||||||
args.sourceTask.id,
|
|
||||||
'owner',
|
|
||||||
);
|
|
||||||
if (!latestOwnerFinal) {
|
if (!latestOwnerFinal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -293,16 +298,27 @@ export function resolveOwnerTaskForHumanMessage(args: {
|
|||||||
args.existingTask ?? getLatestOpenPairedTaskForChat(args.chatJid) ?? null;
|
args.existingTask ?? getLatestOpenPairedTaskForChat(args.chatJid) ?? null;
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
maybeRecordTaskDoneReopen(getLatestPairedTaskForChat(args.chatJid) ?? null);
|
const previousTask = getLatestPairedTaskForChat(args.chatJid) ?? null;
|
||||||
return {
|
maybeRecordTaskDoneReopen(previousTask);
|
||||||
task: canonicalWorkDir
|
const newTask = canonicalWorkDir
|
||||||
? createActiveTaskForRoom({
|
? createActiveTaskForRoom({
|
||||||
group: args.group,
|
group: args.group,
|
||||||
chatJid: args.chatJid,
|
chatJid: args.chatJid,
|
||||||
canonicalWorkDir,
|
canonicalWorkDir,
|
||||||
roomRoleContext: args.roomRoleContext,
|
roomRoleContext: args.roomRoleContext,
|
||||||
})
|
})
|
||||||
: null,
|
: null;
|
||||||
|
// Cold start after the previous task already closed (completed): seed the
|
||||||
|
// fresh task with the previous task's latest owner final so the owner keeps
|
||||||
|
// continuity across sessions instead of answering with "no context".
|
||||||
|
if (newTask && previousTask) {
|
||||||
|
carryForwardLatestOwnerFinal({
|
||||||
|
sourceTask: previousTask,
|
||||||
|
targetTask: newTask,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
task: newTask,
|
||||||
supersededTask: null,
|
supersededTask: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { getClaudeCredentialsPath } from './claude-credentials-path.js';
|
||||||
import {
|
import {
|
||||||
CURRENT_RUNTIME_AGENT_TYPE,
|
CURRENT_RUNTIME_AGENT_TYPE,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
@@ -236,6 +237,7 @@ export function getRuntimeInventory(
|
|||||||
pathSnapshot('Claude settings.json', claudeSettingsPath),
|
pathSnapshot('Claude settings.json', claudeSettingsPath),
|
||||||
pathSnapshot(
|
pathSnapshot(
|
||||||
'Claude credentials',
|
'Claude credentials',
|
||||||
|
getClaudeCredentialsPath(0, { allowHomeFallback: true }) ??
|
||||||
path.join(homeDir, '.claude', '.credentials.json'),
|
path.join(homeDir, '.claude', '.credentials.json'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('service-routing global failover', () => {
|
|||||||
expect(getEffectiveChannelLease('dc:paired')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:paired')).toMatchObject({
|
||||||
chat_jid: 'dc:paired',
|
chat_jid: 'dc:paired',
|
||||||
owner_service_id: 'codex-review',
|
owner_service_id: 'codex-review',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'codex-review',
|
||||||
owner_failover_active: true,
|
owner_failover_active: true,
|
||||||
reason: 'claude-429',
|
reason: 'claude-429',
|
||||||
explicit: true,
|
explicit: true,
|
||||||
@@ -115,8 +115,8 @@ describe('service-routing global failover', () => {
|
|||||||
expect(getGlobalFailoverInfo().active).toBe(false);
|
expect(getGlobalFailoverInfo().active).toBe(false);
|
||||||
expect(getEffectiveChannelLease('dc:paired')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:paired')).toMatchObject({
|
||||||
chat_jid: 'dc:paired',
|
chat_jid: 'dc:paired',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'codex-review',
|
||||||
owner_failover_active: false,
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
@@ -141,7 +141,7 @@ describe('service-routing global failover', () => {
|
|||||||
|
|
||||||
expect(getEffectiveChannelLease('dc:explicit-single')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:explicit-single')).toMatchObject({
|
||||||
chat_jid: 'dc:explicit-single',
|
chat_jid: 'dc:explicit-single',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
owner_failover_active: false,
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -210,7 +210,7 @@ describe('service-routing global failover', () => {
|
|||||||
expect(getEffectiveChannelLease('dc:explicit-tribunal')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:explicit-tribunal')).toMatchObject({
|
||||||
chat_jid: 'dc:explicit-tribunal',
|
chat_jid: 'dc:explicit-tribunal',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'codex-review',
|
||||||
owner_failover_active: false,
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
@@ -231,7 +231,7 @@ describe('service-routing global failover', () => {
|
|||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
chat_jid: 'dc:explicit-tribunal-codex',
|
chat_jid: 'dc:explicit-tribunal-codex',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: 'claude',
|
reviewer_service_id: 'codex-review',
|
||||||
owner_failover_active: false,
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
@@ -263,7 +263,7 @@ describe('service-routing global failover', () => {
|
|||||||
it('defaults to the configured owner service for chats without canonical room settings', () => {
|
it('defaults to the configured owner service for chats without canonical room settings', () => {
|
||||||
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
|
||||||
chat_jid: 'dc:unregistered',
|
chat_jid: 'dc:unregistered',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
owner_failover_active: false,
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -289,7 +289,7 @@ describe('service-routing global failover', () => {
|
|||||||
|
|
||||||
expect(getEffectiveChannelLease('dc:legacy-only')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:legacy-only')).toMatchObject({
|
||||||
chat_jid: 'dc:legacy-only',
|
chat_jid: 'dc:legacy-only',
|
||||||
owner_service_id: 'codex-main',
|
owner_service_id: 'claude',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
owner_failover_active: false,
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
@@ -344,6 +344,8 @@ describe('stored lease ids as SSOT', () => {
|
|||||||
activated_at: '2026-04-09T00:00:00.000Z',
|
activated_at: '2026-04-09T00:00:00.000Z',
|
||||||
reason: 'ssot-test',
|
reason: 'ssot-test',
|
||||||
});
|
});
|
||||||
|
// A stored reviewer lease only applies while the room is paired (tribunal).
|
||||||
|
setExplicitRoomMode('dc:stored-reviewer-ssot', 'tribunal');
|
||||||
refreshChannelOwnerCache(true);
|
refreshChannelOwnerCache(true);
|
||||||
|
|
||||||
const lease = getEffectiveChannelLease('dc:stored-reviewer-ssot');
|
const lease = getEffectiveChannelLease('dc:stored-reviewer-ssot');
|
||||||
@@ -359,3 +361,53 @@ describe('stored lease ids as SSOT', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('single-mode rooms never carry a reviewer lease', () => {
|
||||||
|
it('suppresses a stale stored reviewer lease when the room is single, keeping the owner', () => {
|
||||||
|
// Reproduces the incident: a room was tribunal, got a stored reviewer
|
||||||
|
// lease, then was switched to single. The stale reviewer must not route
|
||||||
|
// single-mode messages into the paired path.
|
||||||
|
setChannelOwnerLease({
|
||||||
|
chat_jid: 'dc:stale-single',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
activated_at: '2026-05-27T00:00:00.000Z',
|
||||||
|
reason: 'was-tribunal',
|
||||||
|
});
|
||||||
|
setExplicitRoomMode('dc:stale-single', 'single');
|
||||||
|
refreshChannelOwnerCache(true);
|
||||||
|
|
||||||
|
expect(getEffectiveChannelLease('dc:stale-single')).toMatchObject({
|
||||||
|
chat_jid: 'dc:stale-single',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: null,
|
||||||
|
arbiter_service_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the stored reviewer lease when the room is switched back to tribunal', () => {
|
||||||
|
setChannelOwnerLease({
|
||||||
|
chat_jid: 'dc:toggle-mode',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
activated_at: '2026-05-27T00:00:00.000Z',
|
||||||
|
reason: 'toggle',
|
||||||
|
});
|
||||||
|
setExplicitRoomMode('dc:toggle-mode', 'single');
|
||||||
|
refreshChannelOwnerCache(true);
|
||||||
|
expect(
|
||||||
|
getEffectiveChannelLease('dc:toggle-mode').reviewer_service_id,
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
setExplicitRoomMode('dc:toggle-mode', 'tribunal');
|
||||||
|
refreshChannelOwnerCache(true);
|
||||||
|
expect(getEffectiveChannelLease('dc:toggle-mode')).toMatchObject({
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -146,13 +146,36 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enforceRoomModeOnLease(
|
||||||
|
chatJid: string,
|
||||||
|
lease: EffectiveChannelLease,
|
||||||
|
): EffectiveChannelLease {
|
||||||
|
// Invariant: a single-mode room never runs a reviewer/arbiter, even if a
|
||||||
|
// stored lease still carries them from when the room was tribunal. Without
|
||||||
|
// this, a stale reviewer lease keeps routing single-mode messages into the
|
||||||
|
// paired path, where they stall forever on a stuck/mismatched execution
|
||||||
|
// lease (the "task revision was already claimed elsewhere" hang). The stored
|
||||||
|
// row is left untouched, so switching the room back to tribunal restores it.
|
||||||
|
if (
|
||||||
|
(lease.reviewer_service_id == null && lease.arbiter_service_id == null) ||
|
||||||
|
getEffectiveRuntimeRoomMode(chatJid) !== 'single'
|
||||||
|
) {
|
||||||
|
return lease;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...lease,
|
||||||
|
reviewer_agent_type: null,
|
||||||
|
arbiter_agent_type: null,
|
||||||
|
reviewer_service_id: null,
|
||||||
|
arbiter_service_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getStoredOrDefaultLease(chatJid: string): EffectiveChannelLease {
|
function getStoredOrDefaultLease(chatJid: string): EffectiveChannelLease {
|
||||||
refreshChannelOwnerCache();
|
refreshChannelOwnerCache();
|
||||||
const row = leaseCache.get(chatJid);
|
const row = leaseCache.get(chatJid);
|
||||||
if (row) {
|
const lease = row ? normalizeLeaseRow(row, true) : getDefaultLease(chatJid);
|
||||||
return normalizeLeaseRow(row, true);
|
return enforceRoomModeOnLease(chatJid, lease);
|
||||||
}
|
|
||||||
return getDefaultLease(chatJid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshChannelOwnerCache(force = false): void {
|
export function refreshChannelOwnerCache(force = false): void {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { AgentType } from './types.js';
|
|||||||
export interface StatusSnapshotEntry {
|
export interface StatusSnapshotEntry {
|
||||||
jid: string;
|
jid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
chatName?: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
agentType: AgentType;
|
agentType: AgentType;
|
||||||
status: GroupStatus['status'];
|
status: GroupStatus['status'];
|
||||||
|
|||||||
Reference in New Issue
Block a user