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:
Codex
2026-08-25 18:40:14 +09:00
parent 80df025672
commit 712664ca00
17 changed files with 457 additions and 91 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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',
}); });
}); });

View File

@@ -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')

View File

@@ -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

View File

@@ -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 === '?') {

View File

@@ -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');
});
});

View File

@@ -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,

View File

@@ -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) };

View File

@@ -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' }),
);
});
}); });

View File

@@ -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({

View File

@@ -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,
}; };
} }

View File

@@ -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'),
), ),
], ],

View File

@@ -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',
});
});
});

View File

@@ -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 {

View File

@@ -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'];