Refactor local memory and role-fixed runtime routing

This commit is contained in:
Eyejoker
2026-04-04 03:50:28 +09:00
parent ca58e8c8eb
commit 3dd772c229
47 changed files with 6158 additions and 669 deletions

View File

@@ -2,10 +2,13 @@
# Copy this file to .env and fill in the values.
# Single unified service manages all three Discord bots in one process.
# --- Discord bots (3 tokens for 3 bots) ---
DISCORD_BOT_TOKEN= # Claude bot
DISCORD_CODEX_BOT_TOKEN= # Codex-main bot (owner)
DISCORD_REVIEW_BOT_TOKEN= # Codex-review bot (fallback reviewer)
# --- Discord bots (canonical role-fixed names) ---
DISCORD_OWNER_BOT_TOKEN= # Owner bot
DISCORD_REVIEWER_BOT_TOKEN= # Reviewer bot
DISCORD_ARBITER_BOT_TOKEN= # Arbiter bot
# Old service-based token names are no longer accepted.
# Rename any existing values to the three canonical role-based keys above.
# --- Agent type configuration ---
OWNER_AGENT_TYPE=codex # codex | claude-code

View File

@@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Originally derived f
## Quick Context
Single unified service (`ejclaw`) manages three Discord bots (Claude, Codex-main, Codex-review) in one process. Owner/reviewer agent types are configurable via `OWNER_AGENT_TYPE` and `REVIEWER_AGENT_TYPE` in `.env`. Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`).
Single unified service (`ejclaw`) manages three Discord bots (owner, reviewer, arbiter) in one process. Agent types behind each role remain configurable via `OWNER_AGENT_TYPE`, `REVIEWER_AGENT_TYPE`, and `ARBITER_AGENT_TYPE` in `.env`. Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`).
## Key Files
@@ -57,9 +57,10 @@ bun run deploy
Single unified service manages all three Discord bots in one process:
- `ejclaw.service` — Single unified process
- Discord bots: `DISCORD_BOT_TOKEN` (Claude), `DISCORD_CODEX_BOT_TOKEN` (Codex-main), `DISCORD_REVIEW_BOT_TOKEN` (Codex-review)
- Discord bots: `DISCORD_OWNER_BOT_TOKEN` (owner), `DISCORD_REVIEWER_BOT_TOKEN` (reviewer), `DISCORD_ARBITER_BOT_TOKEN` (arbiter)
- Old service-based token names are no longer accepted; migrate them to the canonical role-based keys above before starting the service
- Paired review: owner (`OWNER_AGENT_TYPE`, default: codex) ↔ reviewer (`REVIEWER_AGENT_TYPE`, default: claude-code)
- Reviewer fallback: Claude 429/한도초과 시 codex-review로 자동 핸드오프
- Provider fallback is internal routing only — visible Discord bots stay owner/reviewer/arbiter fixed
- Shared dirs: `store/`, `groups/`, `data/`
- SQLite WAL mode + `busy_timeout=5000` for concurrent access

View File

@@ -14,13 +14,25 @@ Tribunal arbiter system inspired by multi-agent consensus architectures.
## Overview
A single unified service (`ejclaw`) manages three Discord bots in one process:
A single unified service (`ejclaw`) runs a **Tribunal** of three roles while
managing three Discord bots in one process:
- **Codex-main** (`@codex`) — Owner agent. Handles user requests, writes code.
- **Claude** (`@claude`) — Reviewer agent. Critically reviews owner's work, verifies design direction.
- **Codex-review** (`@codex-review`) — Arbiter agent. Summoned on-demand to break deadlocks between owner and reviewer.
| Role | Purpose |
|------|---------|
| **Owner** | Handles user requests, writes code |
| **Reviewer** | Critically reviews owner's work, verifies design direction |
| **Arbiter** | On-demand deadlock breaker between owner and reviewer |
All agent types and models are independently configurable per role via `.env`.
The identity layer is role-fixed:
- **Owner bot** — Handles the owner turn output slot.
- **Reviewer bot** — Handles the reviewer turn output slot.
- **Arbiter bot** — Handles the arbiter turn output slot.
Each role's agent type and model are independently configurable via `.env`
(`OWNER_AGENT_TYPE`, `REVIEWER_AGENT_TYPE`, `ARBITER_AGENT_TYPE`, `*_MODEL`).
Three Discord bots provide the identity layer — which bot speaks is determined
by the active role, not hardcoded.
## Room Assignment Model
@@ -41,7 +53,7 @@ Operationally:
This means tribunal is no longer inferred from “two bots registered on one room”; it is an explicit room setting.
## Tribunal 3-Agent System
## Tribunal Flow
```
User message
@@ -131,7 +143,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
- Docker (required for reviewer container isolation)
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
- Discord bot tokens (3: Claude, Codex-main, Codex-review/Arbiter)
- Discord bot tokens (3: owner, reviewer, arbiter)
### Install
@@ -144,15 +156,23 @@ bun run build
bun run build:container # Build reviewer Docker image
```
## Documentation
- [Architecture](docs/architecture.md) — Data flow, room model, container isolation, key files
- [Configuration](docs/configuration.md) — Full `.env` reference, debugging paths
### Environment
All configuration in a single `.env` file:
```bash
# Discord bots (3 tokens for 3 bots)
DISCORD_BOT_TOKEN= # Claude bot
DISCORD_CODEX_BOT_TOKEN= # Codex-main bot (owner)
DISCORD_REVIEW_BOT_TOKEN= # Codex-review bot (arbiter)
# Discord bots (canonical role-fixed names)
DISCORD_OWNER_BOT_TOKEN= # Owner bot
DISCORD_REVIEWER_BOT_TOKEN= # Reviewer bot
DISCORD_ARBITER_BOT_TOKEN= # Arbiter bot
# Old service-based token names are no longer accepted.
# Rename existing values to the canonical role-based keys above.
# Agent types
OWNER_AGENT_TYPE=codex # codex | claude-code

View File

@@ -45,6 +45,46 @@ function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
}).trim();
}
function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string {
const candidateRoots = [
baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT,
baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null,
process.cwd()
? path.join(process.cwd(), '.ejclaw-reviewer-runtime')
: null,
path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'),
].filter((value): value is string => Boolean(value));
const probeContents = '#!/usr/bin/env bash\nexit 0\n';
const tried = new Set<string>();
for (const candidateRoot of candidateRoots) {
if (tried.has(candidateRoot)) {
continue;
}
tried.add(candidateRoot);
try {
fs.mkdirSync(candidateRoot, { recursive: true });
const wrapperDir = fs.mkdtempSync(
path.join(candidateRoot, 'ejclaw-reviewer-git-'),
);
const probePath = path.join(wrapperDir, 'probe');
fs.writeFileSync(probePath, probeContents, { mode: 0o755 });
execFileSync(probePath, [], {
stdio: ['ignore', 'ignore', 'ignore'],
});
fs.rmSync(probePath, { force: true });
return wrapperDir;
} catch {
continue;
}
}
throw new Error(
'Unable to create an executable git guard wrapper directory for reviewer runtime.',
);
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
@@ -55,9 +95,7 @@ export function buildReviewerGitGuardEnv(
const realGitPath = resolveGitBinary(baseEnv);
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
const wrapperDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
);
const wrapperDir = createExecutableWrapperDir(baseEnv);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)

View File

@@ -62,6 +62,27 @@ describe('claude reviewer runtime guard', () => {
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
});
it('prefers an executable HOME-scoped wrapper dir before tmp', () => {
const homeDir = fs.mkdtempSync(
path.join(process.cwd(), '.ejclaw-reviewer-home-'),
);
try {
const env = buildReviewerGitGuardEnv(
{
PATH: process.env.PATH,
HOME: homeDir,
},
true,
);
const wrapperDir = env.PATH?.split(path.delimiter)[0] ?? '';
expect(wrapperDir).toContain(
path.join(homeDir, '.ejclaw-reviewer-runtime'),
);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
it('allows mutating git commands in temp repos outside the protected workspace', () => {
const protectedDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-protected-workspace-'),

View File

@@ -42,6 +42,46 @@ function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
}).trim();
}
function createExecutableWrapperDir(baseEnv: NodeJS.ProcessEnv): string {
const candidateRoots = [
baseEnv.EJCLAW_REVIEWER_GIT_WRAPPER_ROOT,
baseEnv.HOME ? path.join(baseEnv.HOME, '.ejclaw-reviewer-runtime') : null,
process.cwd()
? path.join(process.cwd(), '.ejclaw-reviewer-runtime')
: null,
path.join(os.tmpdir(), '.ejclaw-reviewer-runtime'),
].filter((value): value is string => Boolean(value));
const probeContents = '#!/usr/bin/env bash\nexit 0\n';
const tried = new Set<string>();
for (const candidateRoot of candidateRoots) {
if (tried.has(candidateRoot)) {
continue;
}
tried.add(candidateRoot);
try {
fs.mkdirSync(candidateRoot, { recursive: true });
const wrapperDir = fs.mkdtempSync(
path.join(candidateRoot, 'ejclaw-reviewer-git-'),
);
const probePath = path.join(wrapperDir, 'probe');
fs.writeFileSync(probePath, probeContents, { mode: 0o755 });
execFileSync(probePath, [], {
stdio: ['ignore', 'ignore', 'ignore'],
});
fs.rmSync(probePath, { force: true });
return wrapperDir;
} catch {
continue;
}
}
throw new Error(
'Unable to create an executable git guard wrapper directory for reviewer runtime.',
);
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
@@ -52,9 +92,7 @@ export function buildReviewerGitGuardEnv(
const realGitPath = resolveGitBinary(baseEnv);
const protectedWorkDir = baseEnv.EJCLAW_WORK_DIR || '';
const wrapperDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
);
const wrapperDir = createExecutableWrapperDir(baseEnv);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)

View File

@@ -49,6 +49,27 @@ describe('codex reviewer runtime guard', () => {
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
});
it('prefers an executable HOME-scoped wrapper dir before tmp', () => {
const homeDir = fs.mkdtempSync(
path.join(process.cwd(), '.ejclaw-reviewer-home-'),
);
try {
const env = buildReviewerGitGuardEnv(
{
PATH: process.env.PATH,
HOME: homeDir,
},
true,
);
const wrapperDir = env.PATH?.split(path.delimiter)[0] ?? '';
expect(wrapperDir).toContain(
path.join(homeDir, '.ejclaw-reviewer-runtime'),
);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
it('allows mutating git commands in temp repos outside the protected workspace', () => {
const protectedDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-protected-workspace-'),

View File

@@ -0,0 +1,123 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const { execSyncMock } = vi.hoisted(() => ({
execSyncMock: vi.fn(),
}));
vi.mock('child_process', () => ({
execSync: execSyncMock,
}));
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
} from './legacy-service-guard.js';
describe('legacy service guard', () => {
const tempRoots: string[] = [];
afterEach(() => {
execSyncMock.mockReset();
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
it('detects legacy systemd units in the opposite scope', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-'));
tempRoots.push(projectRoot);
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'systemctl is-active ejclaw-codex') {
throw new Error('inactive');
}
if (cmd === 'systemctl list-unit-files') {
return 'ejclaw-codex.service enabled\n';
}
if (cmd === 'systemctl --user is-active ejclaw-codex') {
throw new Error('inactive');
}
if (cmd === 'systemctl --user list-unit-files') {
return '';
}
if (cmd === 'systemctl is-active ejclaw-review') {
throw new Error('inactive');
}
if (cmd === 'systemctl --user is-active ejclaw-review') {
throw new Error('inactive');
}
if (cmd === 'systemctl list-unit-files' || cmd === 'systemctl --user list-unit-files') {
return '';
}
throw new Error(`unexpected command: ${cmd}`);
});
expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['systemd-system'],
},
]);
});
it('detects legacy nohup artifacts even on systemd hosts', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-'));
tempRoots.push(projectRoot);
fs.writeFileSync(path.join(projectRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n');
execSyncMock.mockImplementation((cmd: string) => {
if (
cmd === 'systemctl is-active ejclaw-codex' ||
cmd === 'systemctl --user is-active ejclaw-codex' ||
cmd === 'systemctl is-active ejclaw-review' ||
cmd === 'systemctl --user is-active ejclaw-review'
) {
throw new Error('inactive');
}
if (
cmd === 'systemctl list-unit-files' ||
cmd === 'systemctl --user list-unit-files'
) {
return '';
}
throw new Error(`unexpected command: ${cmd}`);
});
expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['nohup'],
},
]);
});
it('formats cleanup instructions for the detected scopes only', () => {
const message = formatLegacyServiceFailureMessage({
projectRoot: '/srv/ejclaw',
serviceManager: 'systemd',
homeDir: '/home/user',
services: [
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['systemd-system', 'nohup'],
},
{
name: 'ejclaw-review',
status: 'running',
sources: ['systemd-user'],
},
],
});
expect(message).toContain('systemctl disable --now ejclaw-codex');
expect(message).toContain('systemctl --user disable --now ejclaw-review');
expect(message).toContain('pkill -F "/srv/ejclaw/ejclaw-codex.pid"');
expect(message).not.toContain('systemctl --user disable --now ejclaw-codex');
});
});

View File

@@ -0,0 +1,209 @@
import os from 'os';
import path from 'path';
import type { ServiceManager } from './platform.js';
import { getLegacyServiceDefs } from './service-defs.js';
import {
checkLaunchdServiceArtifact,
checkNohupServiceArtifact,
checkSystemdServiceInScope,
type ServiceCheck,
type SystemdScope,
} from './verify-services.js';
type LegacySource = 'launchd' | 'systemd-system' | 'systemd-user' | 'nohup';
export interface LegacyServiceIssue extends ServiceCheck {
sources: LegacySource[];
}
function summarizeLegacyStatus(statuses: ServiceCheck['status'][]): ServiceCheck['status'] {
if (statuses.includes('running')) {
return 'running';
}
if (statuses.includes('stopped')) {
return 'stopped';
}
return 'not_found';
}
export function detectLegacyServiceIssues(
projectRoot: string,
serviceManager: ServiceManager,
homeDir = os.homedir(),
): LegacyServiceIssue[] {
return getLegacyServiceDefs(projectRoot)
.map((def) => {
if (serviceManager === 'launchd') {
const status = checkLaunchdServiceArtifact(
def.launchdLabel,
path.join(homeDir, 'Library', 'LaunchAgents', `${def.launchdLabel}.plist`),
);
if (status === 'not_found') return null;
return {
name: def.name,
status,
sources: ['launchd'] as LegacySource[],
};
}
if (serviceManager === 'systemd') {
const scopedChecks: Array<{ source: LegacySource; status: ServiceCheck['status'] }> = [
{
source: 'systemd-system',
status: checkSystemdServiceInScope(def.name, 'system'),
},
{
source: 'systemd-user',
status: checkSystemdServiceInScope(def.name, 'user'),
},
{
source: 'nohup',
status: checkNohupServiceArtifact(projectRoot, def.name),
},
];
const detected = scopedChecks.filter(
(current) => current.status !== 'not_found',
);
if (detected.length === 0) return null;
return {
name: def.name,
status: summarizeLegacyStatus(detected.map((current) => current.status)),
sources: detected.map((current) => current.source),
};
}
const status = checkNohupServiceArtifact(projectRoot, def.name);
if (status === 'not_found') return null;
return {
name: def.name,
status,
sources: ['nohup'] as LegacySource[],
};
})
.filter((service): service is LegacyServiceIssue => Boolean(service));
}
function formatSystemdCleanupForScope(
serviceNames: string[],
homeDir: string,
scope: SystemdScope,
): string[] {
if (serviceNames.length === 0) return [];
const systemctlPrefix = scope === 'system' ? 'systemctl' : 'systemctl --user';
const unitDir =
scope === 'system'
? '/etc/systemd/system'
: path.join(homeDir, '.config', 'systemd', 'user');
const unitPaths = serviceNames
.map((serviceName) =>
JSON.stringify(path.join(unitDir, `${serviceName}.service`)),
)
.join(' ');
return [
`${systemctlPrefix} disable --now ${serviceNames.join(' ')}`,
`rm -f ${unitPaths}`,
`${systemctlPrefix} daemon-reload`,
];
}
function formatLaunchdCleanup(serviceNames: string[], homeDir: string): string {
if (serviceNames.length === 0) {
return '';
}
const plistPaths = serviceNames.map((serviceName) => {
const label = serviceName === 'ejclaw-codex'
? 'com.ejclaw-codex'
: 'com.ejclaw-review';
return path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
});
return [
...plistPaths.map(
(plistPath) =>
`launchctl unload ${JSON.stringify(plistPath)} 2>/dev/null || true`,
),
`rm -f ${plistPaths.map((plistPath) => JSON.stringify(plistPath)).join(' ')}`,
].join('\n');
}
function formatNohupCleanup(
projectRoot: string,
serviceNames: string[],
): string {
if (serviceNames.length === 0) {
return '';
}
const pidPaths = serviceNames
.map((serviceName) => path.join(projectRoot, `${serviceName}.pid`))
.map((currentPath) => JSON.stringify(currentPath));
const wrapperPaths = serviceNames
.map((serviceName) => path.join(projectRoot, `start-${serviceName}.sh`))
.map((currentPath) => JSON.stringify(currentPath));
return [
...pidPaths.map((pidPath) => `pkill -F ${pidPath} 2>/dev/null || true`),
`rm -f ${[...pidPaths, ...wrapperPaths].join(' ')}`,
].join('\n');
}
export function formatLegacyServiceFailureMessage(args: {
projectRoot: string;
serviceManager: ServiceManager;
services: LegacyServiceIssue[];
homeDir?: string;
}): string {
const homeDir = args.homeDir ?? os.homedir();
const details = args.services
.map(
(service) =>
`${service.name}=${service.status} [${service.sources.join(',')}]`,
)
.join(', ');
let cleanupLines: string[] = [];
if (args.serviceManager === 'launchd') {
cleanupLines = formatLaunchdCleanup(
args.services
.filter((service) => service.sources.includes('launchd'))
.map((service) => service.name),
homeDir,
).split('\n');
} else if (args.serviceManager === 'systemd') {
cleanupLines = [
...formatSystemdCleanupForScope(
args.services
.filter((service) => service.sources.includes('systemd-system'))
.map((service) => service.name),
homeDir,
'system',
),
...formatSystemdCleanupForScope(
args.services
.filter((service) => service.sources.includes('systemd-user'))
.map((service) => service.name),
homeDir,
'user',
),
...formatNohupCleanup(
args.projectRoot,
args.services
.filter((service) => service.sources.includes('nohup'))
.map((service) => service.name),
).split('\n'),
].filter(Boolean);
} else {
cleanupLines = formatNohupCleanup(
args.projectRoot,
args.services.map((service) => service.name),
).split('\n');
}
return [
`Legacy EJClaw multi-service install detected: ${details}`,
'This setup is reinstall-only. Remove the legacy services before continuing.',
'Suggested cleanup:',
...cleanupLines,
].join('\n');
}

View File

@@ -21,8 +21,6 @@ describe('restartStackServices', () => {
it('dispatches stack restart through the oneshot unit by default', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
const execFileSyncImpl = vi.fn();
@@ -33,7 +31,7 @@ describe('restartStackServices', () => {
serviceId: null,
});
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
expect(services).toEqual(['ejclaw']);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
1,
'systemctl',
@@ -43,11 +41,9 @@ describe('restartStackServices', () => {
expect(execFileSyncImpl).toHaveBeenCalledTimes(1);
});
it('restarts and verifies the configured three-service stack in direct mode', () => {
it('restarts and verifies the unified service in direct mode', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
const execFileSyncImpl = vi.fn();
@@ -59,11 +55,11 @@ describe('restartStackServices', () => {
serviceId: null,
});
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
expect(services).toEqual(['ejclaw']);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
1,
'systemctl',
['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'],
['--user', 'restart', 'ejclaw'],
{ stdio: 'ignore' },
);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
@@ -72,18 +68,7 @@ describe('restartStackServices', () => {
['--user', 'is-active', '--quiet', 'ejclaw'],
{ stdio: 'ignore' },
);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
3,
'systemctl',
['--user', 'is-active', '--quiet', 'ejclaw-codex'],
{ stdio: 'ignore' },
);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
4,
'systemctl',
['--user', 'is-active', '--quiet', 'ejclaw-review'],
{ stdio: 'ignore' },
);
expect(execFileSyncImpl).toHaveBeenCalledTimes(2);
});
it('rejects non-systemd environments', () => {
@@ -97,11 +82,34 @@ describe('restartStackServices', () => {
).toThrow('restart:stack only supports Linux systemd services in this repo');
});
it('fails fast when legacy services are still present', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
const execFileSyncImpl = vi.fn();
expect(() =>
restartStackServices(tempRoot, {
direct: true,
execFileSyncImpl,
runningAsRoot: false,
serviceManager: 'systemd',
serviceId: null,
legacyServiceIssues: [
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['systemd-system'],
},
],
}),
).toThrow('Legacy EJClaw multi-service install detected');
expect(execFileSyncImpl).not.toHaveBeenCalled();
});
it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
const execFileSyncImpl = vi
.fn()
@@ -120,7 +128,7 @@ describe('restartStackServices', () => {
serviceId: null,
});
expect(services).toEqual(['ejclaw', 'ejclaw-codex', 'ejclaw-review']);
expect(services).toEqual(['ejclaw']);
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
1,
'systemctl',
@@ -130,7 +138,7 @@ describe('restartStackServices', () => {
expect(execFileSyncImpl).toHaveBeenNthCalledWith(
2,
'systemctl',
['--user', 'restart', 'ejclaw', 'ejclaw-codex', 'ejclaw-review'],
['--user', 'restart', 'ejclaw'],
{ stdio: 'ignore' },
);
});
@@ -138,7 +146,6 @@ describe('restartStackServices', () => {
it('rejects direct fallback when the unit is missing for a managed service caller', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
const execFileSyncImpl = vi.fn().mockImplementation(() => {
const error = new Error('unit missing');
@@ -161,7 +168,6 @@ describe('restartStackServices', () => {
it('does not hide a general start failure behind direct fallback', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
const execFileSyncImpl = vi.fn().mockImplementation(() => {
const error = new Error('job failed');

View File

@@ -1,6 +1,12 @@
import { execFileSync } from 'child_process';
import os from 'os';
import { pathToFileURL } from 'url';
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
type LegacyServiceIssue,
} from './legacy-service-guard.js';
import { getServiceManager, isRoot } from './platform.js';
import { getConfiguredServiceNames } from './service-defs.js';
@@ -17,6 +23,8 @@ interface RestartStackDeps {
serviceManager?: ServiceManager;
direct?: boolean;
serviceId?: string | null;
legacyServiceIssues?: LegacyServiceIssue[];
homeDir?: string;
}
function restartStackServicesDirect(
@@ -62,6 +70,24 @@ export function restartStackServices(
);
}
const legacyServiceIssues =
deps.legacyServiceIssues ??
detectLegacyServiceIssues(
projectRoot,
serviceManager,
deps.homeDir ?? os.homedir(),
);
if (legacyServiceIssues.length > 0) {
throw new Error(
formatLegacyServiceFailureMessage({
projectRoot,
serviceManager,
services: legacyServiceIssues,
homeDir: deps.homeDir,
}),
);
}
const services = getConfiguredServiceNames(projectRoot);
const execImpl = deps.execFileSyncImpl ?? execFileSync;
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];

View File

@@ -1,7 +1,4 @@
import fs from 'fs';
import path from 'path';
export type ServiceKind = 'primary' | 'codex' | 'review';
export type ServiceKind = 'primary' | 'legacy';
export interface ServiceDef {
/** Stable topology kind used by setup/verify logic */
@@ -26,11 +23,9 @@ interface ServiceTemplate {
launchdLabel: string;
description: string;
logName: string;
envFileName?: string;
assistantName?: string;
}
const SERVICE_TEMPLATES: ServiceTemplate[] = [
const CURRENT_SERVICE_TEMPLATES: ServiceTemplate[] = [
{
kind: 'primary',
name: 'ejclaw',
@@ -38,37 +33,27 @@ const SERVICE_TEMPLATES: ServiceTemplate[] = [
description: 'EJClaw Personal Assistant (Claude Code)',
logName: 'ejclaw',
},
];
const LEGACY_SERVICE_TEMPLATES: ServiceTemplate[] = [
{
kind: 'codex',
kind: 'legacy',
name: 'ejclaw-codex',
launchdLabel: 'com.ejclaw-codex',
description: 'EJClaw Codex Assistant',
description: 'Legacy EJClaw Codex Assistant',
logName: 'ejclaw-codex',
envFileName: '.env.codex',
assistantName: 'codex',
},
{
kind: 'review',
kind: 'legacy',
name: 'ejclaw-review',
launchdLabel: 'com.ejclaw-review',
description: 'EJClaw Codex Review Assistant',
description: 'Legacy EJClaw Codex Review Assistant',
logName: 'ejclaw-review',
envFileName: '.env.codex-review',
assistantName: 'codex',
},
];
function materializeServiceDef(
projectRoot: string,
template: ServiceTemplate,
): ServiceDef | null {
const environmentFile = template.envFileName
? path.join(projectRoot, template.envFileName)
: undefined;
if (environmentFile && !fs.existsSync(environmentFile)) {
return null;
}
function materializeServiceDef(template: ServiceTemplate): ServiceDef {
const environmentFile = undefined;
return {
kind: template.kind,
@@ -77,21 +62,28 @@ function materializeServiceDef(
description: template.description,
logName: template.logName,
environmentFile,
extraEnv: template.assistantName
? {
ASSISTANT_NAME: template.assistantName,
}
: undefined,
extraEnv: undefined,
};
}
export function getServiceDefs(projectRoot: string): ServiceDef[] {
return SERVICE_TEMPLATES.flatMap((template) => {
const def = materializeServiceDef(projectRoot, template);
return def ? [def] : [];
});
void projectRoot;
return CURRENT_SERVICE_TEMPLATES.map((template) =>
materializeServiceDef(template),
);
}
export function getLegacyServiceDefs(projectRoot: string): ServiceDef[] {
void projectRoot;
return LEGACY_SERVICE_TEMPLATES.map((template) =>
materializeServiceDef(template),
);
}
export function getConfiguredServiceNames(projectRoot: string): string[] {
return getServiceDefs(projectRoot).map((def) => def.name);
}
export function getLegacyServiceNames(projectRoot: string): string[] {
return getLegacyServiceDefs(projectRoot).map((def) => def.name);
}

View File

@@ -9,7 +9,11 @@ import {
buildStackRestartSystemdUnit,
buildSystemdUnit,
} from './service-renderers.js';
import { getServiceDefs, type ServiceDef } from './service-defs.js';
import {
getLegacyServiceDefs,
getServiceDefs,
type ServiceDef,
} from './service-defs.js';
/**
* Tests for service configuration generation.
@@ -127,19 +131,18 @@ describe('systemd unit generation', () => {
const unit = buildSystemdUnit(
{
...baseServiceDef,
kind: 'codex',
environmentFile: '/srv/ejclaw/.env.codex',
kind: 'primary',
environmentFile: '/srv/ejclaw/.env.extra',
extraEnv: { ASSISTANT_NAME: 'codex' },
logName: 'ejclaw-codex',
name: 'ejclaw-codex',
logName: 'ejclaw',
name: 'ejclaw',
},
'/srv/ejclaw',
'/usr/bin/bun',
'/home/user',
false,
);
expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.codex');
expect(unit).toContain('EnvironmentFile=/srv/ejclaw/.env.extra');
expect(unit).toContain('Environment=ASSISTANT_NAME=codex');
});
});
@@ -173,24 +176,27 @@ describe('service definitions', () => {
}
});
it('includes the review service when .env.codex-review exists', () => {
it('returns only the unified service definition', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-'));
tempRoots.push(tempRoot);
fs.writeFileSync(path.join(tempRoot, '.env.codex'), 'A=1\n');
fs.writeFileSync(path.join(tempRoot, '.env.codex-review'), 'B=1\n');
const defs = getServiceDefs(tempRoot);
expect(defs.map((def) => def.name)).toEqual(['ejclaw']);
expect(defs.map((def) => def.kind)).toEqual(['primary']);
});
it('keeps legacy service identities available for migration guards', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-'));
tempRoots.push(tempRoot);
const defs = getLegacyServiceDefs(tempRoot);
expect(defs.map((def) => def.name)).toEqual([
'ejclaw',
'ejclaw-codex',
'ejclaw-review',
]);
expect(defs.map((def) => def.kind)).toEqual([
'primary',
'codex',
'review',
]);
expect(defs.map((def) => def.kind)).toEqual(['legacy', 'legacy']);
});
it('generates a oneshot stack restart unit', () => {

View File

@@ -2,10 +2,8 @@
* Step: service — Generate and load service manager config.
* Replaces 08-setup-service.sh
*
* Supports the EJClaw service stack:
* - ejclaw (Claude Code) — always installed
* - ejclaw-codex (Codex) — installed when .env.codex exists
* - ejclaw-review (Codex Review) — installed when .env.codex-review exists
* Supports the unified EJClaw service:
* - ejclaw — always installed
*/
import { execSync } from 'child_process';
import fs from 'fs';
@@ -13,7 +11,11 @@ import os from 'os';
import path from 'path';
import { logger } from '../src/logger.js';
import { getPlatform, getNodePath } from './platform.js';
import { getPlatform, getNodePath, getServiceManager } from './platform.js';
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
} from './legacy-service-guard.js';
import { getServiceDefs } from './service-defs.js';
import { setupLaunchd, setupLinux } from './service-installers.js';
import { emitStatus } from './status.js';
@@ -27,9 +29,41 @@ export async function run(_args: string[]): Promise<void> {
const platform = getPlatform();
const nodePath = getNodePath();
const homeDir = os.homedir();
const serviceManager = getServiceManager();
logger.info({ platform, nodePath, projectRoot }, 'Setting up service');
const legacyServiceIssues = detectLegacyServiceIssues(
projectRoot,
serviceManager,
homeDir,
);
if (legacyServiceIssues.length > 0) {
const errorMessage = formatLegacyServiceFailureMessage({
projectRoot,
serviceManager,
homeDir,
services: legacyServiceIssues,
});
logger.error(
{ legacyServiceIssues, serviceManager },
'Legacy multi-service install detected during setup',
);
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: serviceManager,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'legacy_services_detected',
LEGACY_SERVICES: legacyServiceIssues
.map((service) => `${service.name}:${service.status}`)
.join(','),
LOG: 'logs/setup.log',
});
console.error(errorMessage);
process.exit(1);
}
// Build first
logger.info('Building TypeScript');
try {
@@ -54,14 +88,6 @@ export async function run(_args: string[]): Promise<void> {
fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true });
const serviceDefs = getServiceDefs(projectRoot);
for (const def of serviceDefs) {
if (def.kind === 'primary' || !def.environmentFile) {
continue;
}
logger.info(
`Detected ${path.basename(def.environmentFile)} — will also install ${def.name} service`,
);
}
if (platform === 'macos') {
for (const def of serviceDefs) {

View File

@@ -18,8 +18,11 @@ vi.mock('./platform.js', () => ({
import {
checkLaunchdService,
checkLaunchdServiceArtifact,
checkNohupService,
checkNohupServiceArtifact,
checkSystemdService,
checkSystemdServiceInScope,
getServiceChecks,
} from './verify-services.js';
import type { ServiceDef } from './service-defs.js';
@@ -54,6 +57,43 @@ describe('verify service checks', () => {
});
});
it('checks systemd services in both explicit scopes', () => {
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'systemctl is-active ejclaw-codex') {
return undefined;
}
if (cmd === 'systemctl --user is-active ejclaw-codex') {
throw new Error('inactive');
}
if (cmd === 'systemctl --user list-unit-files') {
return '';
}
throw new Error(`unexpected command: ${cmd}`);
});
expect(checkSystemdServiceInScope('ejclaw-codex', 'system')).toBe('running');
expect(checkSystemdServiceInScope('ejclaw-codex', 'user')).toBe('not_found');
});
it('treats an unloaded launchd plist as stopped when artifact detection is enabled', () => {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-launchd-'));
const plistPath = path.join(
tempHome,
'Library',
'LaunchAgents',
'com.ejclaw-codex.plist',
);
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
fs.writeFileSync(plistPath, '<plist />');
execSyncMock.mockReturnValue('');
expect(
checkLaunchdServiceArtifact('com.ejclaw-codex', plistPath),
).toBe('stopped');
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('treats known but inactive systemd services as stopped', () => {
execSyncMock
.mockImplementationOnce(() => {
@@ -79,6 +119,15 @@ describe('verify service checks', () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('treats a legacy nohup wrapper without a live pid as stopped when artifact detection is enabled', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
fs.writeFileSync(path.join(tempRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n');
expect(checkNohupServiceArtifact(tempRoot, 'ejclaw-codex')).toBe('stopped');
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('builds per-service status checks from service definitions', () => {
const defs: ServiceDef[] = [
{
@@ -89,18 +138,18 @@ describe('verify service checks', () => {
logName: 'ejclaw',
},
{
kind: 'codex',
name: 'ejclaw-codex',
description: 'Codex',
launchdLabel: 'com.ejclaw.codex',
logName: 'ejclaw-codex',
kind: 'primary',
name: 'ejclaw-secondary',
description: 'Secondary',
launchdLabel: 'com.ejclaw.secondary',
logName: 'ejclaw-secondary',
},
];
execSyncMock.mockReturnValue('123\t0\tcom.ejclaw\n');
expect(getServiceChecks(defs, '/tmp/ejclaw', 'launchd')).toEqual([
{ name: 'ejclaw', status: 'running' },
{ name: 'ejclaw-codex', status: 'not_found' },
{ name: 'ejclaw-secondary', status: 'not_found' },
]);
});
});

View File

@@ -16,6 +16,13 @@ export interface ServiceCheck {
status: ServiceStatus;
}
export interface ServiceCheckOptions {
detectArtifacts?: boolean;
homeDir?: string;
}
export type SystemdScope = 'system' | 'user';
export function checkLaunchdService(label: string): ServiceStatus {
try {
const output = execSync('launchctl list', { encoding: 'utf-8' });
@@ -32,8 +39,26 @@ export function checkLaunchdService(label: string): ServiceStatus {
return 'not_found';
}
export function checkSystemdService(name: string): ServiceStatus {
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
function getLaunchdPlistPath(homeDir: string, launchdLabel: string): string {
return path.join(homeDir, 'Library', 'LaunchAgents', `${launchdLabel}.plist`);
}
export function checkLaunchdServiceArtifact(
label: string,
plistPath: string,
): ServiceStatus {
const status = checkLaunchdService(label);
if (status !== 'not_found') {
return status;
}
return fs.existsSync(plistPath) ? 'stopped' : 'not_found';
}
export function checkSystemdServiceInScope(
name: string,
scope: SystemdScope,
): ServiceStatus {
const prefix = scope === 'system' ? 'systemctl' : 'systemctl --user';
try {
execSync(`${prefix} is-active ${name}`, { stdio: 'ignore' });
return 'running';
@@ -52,6 +77,10 @@ export function checkSystemdService(name: string): ServiceStatus {
return 'not_found';
}
export function checkSystemdService(name: string): ServiceStatus {
return checkSystemdServiceInScope(name, isRoot() ? 'system' : 'user');
}
export function checkNohupService(
projectRoot: string,
serviceName: string,
@@ -72,18 +101,40 @@ export function checkNohupService(
return 'not_found';
}
export function checkNohupServiceArtifact(
projectRoot: string,
serviceName: string,
): ServiceStatus {
const status = checkNohupService(projectRoot, serviceName);
if (status !== 'not_found') {
return status;
}
const wrapperPath = path.join(projectRoot, `start-${serviceName}.sh`);
return fs.existsSync(wrapperPath) ? 'stopped' : 'not_found';
}
export function checkService(
projectRoot: string,
serviceManager: 'launchd' | 'systemd' | 'none',
serviceName: string,
launchdLabel: string,
options: ServiceCheckOptions = {},
): ServiceStatus {
if (serviceManager === 'launchd') {
if (options.detectArtifacts && options.homeDir) {
return checkLaunchdServiceArtifact(
launchdLabel,
getLaunchdPlistPath(options.homeDir, launchdLabel),
);
}
return checkLaunchdService(launchdLabel);
}
if (serviceManager === 'systemd') {
return checkSystemdService(serviceName);
}
if (options.detectArtifacts) {
return checkNohupServiceArtifact(projectRoot, serviceName);
}
return checkNohupService(projectRoot, serviceName);
}
@@ -91,6 +142,7 @@ export function getServiceChecks(
serviceDefs: ServiceDef[],
projectRoot: string,
serviceManager: 'launchd' | 'systemd' | 'none',
options: ServiceCheckOptions = {},
): ServiceCheck[] {
return serviceDefs.map((def) => ({
name: def.name,
@@ -99,6 +151,7 @@ export function getServiceChecks(
serviceManager,
def.name,
def.launchdLabel,
options,
),
}));
}

View File

@@ -11,7 +11,9 @@ import {
buildVerifySummary,
detectChannelAuth,
detectCredentials,
detectLegacyDiscordTokenKeys,
loadRegisteredGroupsSummary,
loadRoleRoutingRequirementsSummary,
} from './verify-state.js';
describe('verify state helpers', () => {
@@ -34,14 +36,88 @@ describe('verify state helpers', () => {
expect(detectCredentials(tempRoot)).toBe('configured');
});
it('detects channel auth from either env source', () => {
it('detects canonical role-based channel auth names from process env', () => {
expect(
detectChannelAuth(
{ DISCORD_BOT_TOKEN: '' },
{ DISCORD_BOT_TOKEN: 'discord-token' },
{},
{
DISCORD_OWNER_BOT_TOKEN: 'owner-token',
DISCORD_REVIEWER_BOT_TOKEN: 'reviewer-token',
DISCORD_ARBITER_BOT_TOKEN: 'arbiter-token',
},
),
).toEqual({
discord: 'configured',
'discord-review': 'configured',
'discord-arbiter': 'configured',
});
});
it('does not treat legacy service-based channel auth names as configured channels', () => {
expect(
detectChannelAuth(
{},
{
DISCORD_CLAUDE_BOT_TOKEN: 'legacy-owner-token',
DISCORD_CODEX_MAIN_BOT_TOKEN: 'legacy-reviewer-token',
DISCORD_CODEX_REVIEW_BOT_TOKEN: 'legacy-arbiter-token',
},
),
).toEqual({});
});
it('detects legacy service-based discord token names from env file and process env', () => {
expect(
detectLegacyDiscordTokenKeys(
{
DISCORD_BOT_TOKEN: 'legacy-owner-token',
},
{
DISCORD_CODEX_MAIN_BOT_TOKEN: 'legacy-reviewer-token',
DISCORD_CODEX_REVIEW_BOT_TOKEN: 'legacy-arbiter-token',
},
),
).toEqual([
'DISCORD_BOT_TOKEN',
'DISCORD_CODEX_MAIN_BOT_TOKEN',
'DISCORD_CODEX_REVIEW_BOT_TOKEN',
]);
});
it('loads paired-room routing requirements from the sqlite store', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
tempRoots.push(tempRoot);
const dbPath = path.join(tempRoot, 'messages.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE registered_groups (
jid TEXT NOT NULL,
agent_type TEXT
);
`);
db.exec(`
CREATE TABLE paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
status TEXT NOT NULL
);
`);
db.exec(`
INSERT INTO registered_groups (jid, agent_type) VALUES
('group-1', 'claude-code'),
('group-1', 'codex'),
('group-2', 'claude-code');
`);
db.exec(`
INSERT INTO paired_tasks (id, chat_jid, status) VALUES
('task-1', 'group-1', 'arbiter_requested'),
('task-2', 'group-2', 'completed');
`);
db.close();
expect(loadRoleRoutingRequirementsSummary(dbPath)).toEqual({
tribunalRooms: 1,
activeArbiterTasks: 1,
});
});
@@ -83,13 +159,6 @@ describe('verify state helpers', () => {
launchdLabel: 'com.ejclaw',
logName: 'ejclaw',
},
{
kind: 'codex',
name: 'ejclaw-codex',
description: 'Codex',
launchdLabel: 'com.ejclaw.codex',
logName: 'ejclaw-codex',
},
];
expect(
@@ -97,15 +166,19 @@ describe('verify state helpers', () => {
services,
serviceDefs,
'configured',
{ discord: 'configured' },
{
discord: 'configured',
'discord-review': 'configured',
'discord-arbiter': 'configured',
},
2,
{ codex: 1 },
),
).toMatchObject({
status: 'success',
configuredChannels: ['discord'],
configuredChannels: ['discord', 'discord-review', 'discord-arbiter'],
codexConfigured: true,
reviewConfigured: false,
reviewConfigured: true,
servicesSummary: { ejclaw: 'running' },
});
});
@@ -128,4 +201,95 @@ describe('verify state helpers', () => {
servicesSummary: { ejclaw: 'stopped' },
});
});
it('fails verification when only the review channel is configured', () => {
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
expect(
buildVerifySummary(
services,
[],
'configured',
{ 'discord-review': 'configured' },
1,
{},
),
).toMatchObject({
status: 'failed',
configuredChannels: ['discord-review'],
codexConfigured: true,
reviewConfigured: false,
servicesSummary: { ejclaw: 'running' },
});
});
it('fails verification when tribunal rooms exist but the reviewer channel is missing', () => {
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
expect(
buildVerifySummary(
services,
[],
'configured',
{ discord: 'configured' },
1,
{},
{ tribunalRooms: 1 },
),
).toMatchObject({
status: 'failed',
configuredChannels: ['discord'],
codexConfigured: false,
reviewConfigured: false,
tribunalRooms: 1,
});
});
it('fails verification when arbiter work is pending but the arbiter channel is missing', () => {
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
expect(
buildVerifySummary(
services,
[],
'configured',
{
discord: 'configured',
'discord-review': 'configured',
},
1,
{},
{ tribunalRooms: 1, activeArbiterTasks: 1 },
),
).toMatchObject({
status: 'failed',
configuredChannels: ['discord', 'discord-review'],
codexConfigured: true,
reviewConfigured: false,
activeArbiterTasks: 1,
});
});
it('fails verification when legacy discord token names are still configured', () => {
const services: ServiceCheck[] = [{ name: 'ejclaw', status: 'running' }];
expect(
buildVerifySummary(
services,
[],
'configured',
{
discord: 'configured',
'discord-review': 'configured',
'discord-arbiter': 'configured',
},
1,
{},
{ legacyDiscordTokenKeys: ['DISCORD_BOT_TOKEN'] },
),
).toMatchObject({
status: 'failed',
legacyDiscordTokenKeys: ['DISCORD_BOT_TOKEN'],
});
});
});

View File

@@ -11,16 +11,34 @@ import type { ServiceCheck } from './verify-services.js';
export type CredentialsStatus = 'configured' | 'missing';
export type VerifyStatus = 'success' | 'failed';
const LEGACY_DISCORD_TOKEN_KEYS = [
'DISCORD_BOT_TOKEN',
'DISCORD_CODEX_BOT_TOKEN',
'DISCORD_REVIEW_BOT_TOKEN',
'DISCORD_CLAUDE_BOT_TOKEN',
'DISCORD_CODEX_MAIN_BOT_TOKEN',
'DISCORD_CODEX_REVIEW_BOT_TOKEN',
];
export interface RegisteredGroupsSummary {
registeredGroups: number;
groupsByAgent: Record<string, number>;
}
export interface RoleRoutingRequirementsSummary {
tribunalRooms: number;
activeArbiterTasks: number;
}
export interface VerifySummary extends RegisteredGroupsSummary {
status: VerifyStatus;
servicesSummary: Record<string, string>;
configuredChannels: string[];
channelAuth: Record<string, string>;
legacyDiscordTokenKeys: string[];
tribunalRooms: number;
activeArbiterTasks: number;
// Legacy status fields kept for backward-compatible setup output.
codexConfigured: boolean;
reviewConfigured: boolean;
}
@@ -38,18 +56,39 @@ export function detectCredentials(projectRoot: string): CredentialsStatus {
}
export function detectChannelAuth(
envVars = readEnvFile(['DISCORD_BOT_TOKEN']),
envVars = readEnvFile([
'DISCORD_OWNER_BOT_TOKEN',
'DISCORD_REVIEWER_BOT_TOKEN',
'DISCORD_ARBITER_BOT_TOKEN',
]),
processEnv: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const channelAuth: Record<string, string> = {};
if (processEnv.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) {
const hasEnv = (key: string): boolean => !!(processEnv[key] || envVars[key]);
if (hasEnv('DISCORD_OWNER_BOT_TOKEN')) {
channelAuth.discord = 'configured';
}
if (hasEnv('DISCORD_REVIEWER_BOT_TOKEN')) {
channelAuth['discord-review'] = 'configured';
}
if (hasEnv('DISCORD_ARBITER_BOT_TOKEN')) {
channelAuth['discord-arbiter'] = 'configured';
}
return channelAuth;
}
export function detectLegacyDiscordTokenKeys(
envVars = readEnvFile(LEGACY_DISCORD_TOKEN_KEYS),
processEnv: NodeJS.ProcessEnv = process.env,
): string[] {
return LEGACY_DISCORD_TOKEN_KEYS.filter(
(key) => !!(processEnv[key] || envVars[key]),
);
}
export function loadRegisteredGroupsSummary(
dbPath = path.join(STORE_DIR, 'messages.db'),
): RegisteredGroupsSummary {
@@ -88,6 +127,82 @@ export function loadRegisteredGroupsSummary(
return { registeredGroups, groupsByAgent };
}
export function loadRoleRoutingRequirementsSummary(
dbPath = path.join(STORE_DIR, 'messages.db'),
): RoleRoutingRequirementsSummary {
let tribunalRooms = 0;
let activeArbiterTasks = 0;
if (!fs.existsSync(dbPath)) {
return { tribunalRooms, activeArbiterTasks };
}
try {
const db = new Database(dbPath, { readonly: true });
try {
const roomModeRow = db
.prepare(
`
SELECT COUNT(*) as count
FROM (
SELECT chat_jid AS jid
FROM room_settings
WHERE room_mode = 'tribunal'
UNION
SELECT jid
FROM registered_groups
GROUP BY jid
HAVING COUNT(DISTINCT agent_type) > 1
)
`,
)
.get() as { count: number };
tribunalRooms = roomModeRow.count;
} catch {
try {
const fallbackRow = db
.prepare(
`
SELECT COUNT(*) as count
FROM (
SELECT jid
FROM registered_groups
GROUP BY jid
HAVING COUNT(DISTINCT agent_type) > 1
)
`,
)
.get() as { count: number };
tribunalRooms = fallbackRow.count;
} catch {
tribunalRooms = 0;
}
}
try {
const arbiterRow = db
.prepare(
`
SELECT COUNT(*) as count
FROM paired_tasks
WHERE status IN ('arbiter_requested', 'in_arbitration')
`,
)
.get() as { count: number };
activeArbiterTasks = arbiterRow.count;
} catch {
activeArbiterTasks = 0;
}
db.close();
} catch {
// Tables might not exist yet
}
return { tribunalRooms, activeArbiterTasks };
}
export function buildVerifySummary(
services: ServiceCheck[],
serviceDefs: ServiceDef[],
@@ -95,22 +210,33 @@ export function buildVerifySummary(
channelAuth: Record<string, string>,
registeredGroups: number,
groupsByAgent: Record<string, number>,
options: {
legacyDiscordTokenKeys?: string[];
tribunalRooms?: number;
activeArbiterTasks?: number;
} = {},
): VerifySummary {
void serviceDefs;
const configuredChannels = Object.keys(channelAuth);
const allConfiguredServicesRunning = services.every(
(service) => service.status === 'running',
);
const codexConfigured = serviceDefs.some(
(service) => service.kind === 'codex',
);
const reviewConfigured = serviceDefs.some(
(service) => service.kind === 'review',
);
const hasOwnerCapableChannel = 'discord' in channelAuth;
const codexConfigured = 'discord-review' in channelAuth;
const reviewConfigured = 'discord-arbiter' in channelAuth;
const legacyDiscordTokenKeys = options.legacyDiscordTokenKeys ?? [];
const tribunalRooms = options.tribunalRooms ?? 0;
const activeArbiterTasks = options.activeArbiterTasks ?? 0;
const reviewerConfigured = tribunalRooms === 0 || codexConfigured;
const arbiterConfigured = activeArbiterTasks === 0 || reviewConfigured;
const status =
allConfiguredServicesRunning &&
credentials === 'configured' &&
configuredChannels.length > 0 &&
hasOwnerCapableChannel &&
legacyDiscordTokenKeys.length === 0 &&
reviewerConfigured &&
arbiterConfigured &&
registeredGroups > 0
? 'success'
: 'failed';
@@ -125,6 +251,9 @@ export function buildVerifySummary(
servicesSummary,
configuredChannels,
channelAuth,
legacyDiscordTokenKeys,
tribunalRooms,
activeArbiterTasks,
registeredGroups,
groupsByAgent,
codexConfigured,

View File

@@ -2,14 +2,18 @@
* Step: verify — End-to-end health check of the full installation.
* Replaces 09-verify.sh
*
* Supports the EJClaw service stack:
* - ejclaw (Claude Code) — always checked
* - ejclaw-codex (Codex) — checked when .env.codex exists
* - ejclaw-review (Codex Review) — checked when .env.codex-review exists
* Supports the unified EJClaw service:
* - ejclaw — always checked
*
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
*/
import os from 'os';
import { logger } from '../src/logger.js';
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
} from './legacy-service-guard.js';
import { getServiceManager } from './platform.js';
import { getServiceDefs } from './service-defs.js';
import { emitStatus } from './status.js';
@@ -17,7 +21,9 @@ import {
buildVerifySummary,
detectChannelAuth,
detectCredentials,
detectLegacyDiscordTokenKeys,
loadRegisteredGroupsSummary,
loadRoleRoutingRequirementsSummary,
} from './verify-state.js';
import { getServiceChecks } from './verify-services.js';
@@ -34,18 +40,35 @@ export async function run(_args: string[]): Promise<void> {
// 1. Check service statuses
const serviceDefs = getServiceDefs(projectRoot);
const services = getServiceChecks(serviceDefs, projectRoot, mgr);
const legacyServiceIssues = detectLegacyServiceIssues(
projectRoot,
mgr,
os.homedir(),
);
for (const svc of services) {
logger.info({ service: svc.name, status: svc.status }, 'Service status');
}
for (const svc of legacyServiceIssues) {
logger.error(
{ service: svc.name, status: svc.status },
'Legacy service detected during verification',
);
}
const credentials = detectCredentials(projectRoot);
const channelAuth = detectChannelAuth();
const legacyDiscordTokenKeys = detectLegacyDiscordTokenKeys();
const { registeredGroups, groupsByAgent } = loadRegisteredGroupsSummary();
const { tribunalRooms, activeArbiterTasks } =
loadRoleRoutingRequirementsSummary();
const {
status,
status: baseStatus,
servicesSummary,
configuredChannels,
legacyDiscordTokenKeys: legacyDiscordTokens,
tribunalRooms: detectedTribunalRooms,
activeArbiterTasks: detectedActiveArbiterTasks,
codexConfigured,
reviewConfigured,
} = buildVerifySummary(
@@ -55,17 +78,64 @@ export async function run(_args: string[]): Promise<void> {
channelAuth,
registeredGroups,
groupsByAgent,
{
legacyDiscordTokenKeys,
tribunalRooms,
activeArbiterTasks,
},
);
const legacyServicesSummary = Object.fromEntries(
legacyServiceIssues.map((service) => [service.name, service.status]),
);
const status = legacyServiceIssues.length > 0 ? 'failed' : baseStatus;
logger.info({ status, channelAuth, servicesSummary }, 'Verification complete');
logger.info(
{
status,
channelAuth,
legacyDiscordTokens,
tribunalRooms: detectedTribunalRooms,
activeArbiterTasks: detectedActiveArbiterTasks,
servicesSummary,
legacyServicesSummary,
},
'Verification complete',
);
if (legacyDiscordTokens.length > 0) {
logger.error(
{
legacyDiscordTokens,
migration:
'Rename Discord bot tokens to DISCORD_OWNER_BOT_TOKEN / DISCORD_REVIEWER_BOT_TOKEN / DISCORD_ARBITER_BOT_TOKEN',
},
'Verification failed due to legacy service-based Discord bot token names',
);
}
if (legacyServiceIssues.length > 0) {
logger.error(
{
cleanup: formatLegacyServiceFailureMessage({
projectRoot,
serviceManager: mgr,
homeDir: os.homedir(),
services: legacyServiceIssues,
}),
},
'Verification failed due to legacy multi-service install',
);
}
emitStatus('VERIFY', {
SERVICES: JSON.stringify(servicesSummary),
LEGACY_SERVICES: JSON.stringify(legacyServicesSummary),
// Legacy field (keep for backward compatibility)
SERVICE: services[0].status,
CREDENTIALS: credentials,
CONFIGURED_CHANNELS: configuredChannels.join(','),
CHANNEL_AUTH: JSON.stringify(channelAuth),
LEGACY_DISCORD_TOKENS: legacyDiscordTokens.join(','),
TRIBUNAL_ROOMS: detectedTribunalRooms,
ACTIVE_ARBITER_TASKS: detectedActiveArbiterTasks,
REGISTERED_GROUPS: registeredGroups,
GROUPS_BY_AGENT: JSON.stringify(groupsByAgent),
CODEX_CONFIGURED: codexConfigured,

View File

@@ -99,6 +99,7 @@ export type AgentTriggerReason =
| 'usage-exhausted'
| 'auth-expired'
| 'org-access-denied'
| 'session-failure'
| 'overloaded'
| 'network-error'
| 'success-null-result';

View File

@@ -42,6 +42,7 @@ vi.mock('./service-routing.js', () => ({
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
@@ -77,7 +78,10 @@ vi.mock('os', async () => {
};
});
import { prepareGroupEnvironment } from './agent-runner-environment.js';
import {
prepareContainerSessionEnvironment,
prepareGroupEnvironment,
} from './agent-runner-environment.js';
import * as config from './config.js';
import * as serviceRouting from './service-routing.js';
import type { RegisteredGroup } from './types.js';
@@ -202,8 +206,9 @@ describe('prepareGroupEnvironment codex auth handling', () => {
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'dc:test',
owner_service_id: 'codex-review',
reviewer_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
owner_failover_active: true,
activated_at: '2026-03-28T00:00:00.000Z',
reason: 'claude-429',
explicit: true,
@@ -309,6 +314,7 @@ describe('prepareGroupEnvironment codex auth handling', () => {
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
@@ -334,3 +340,67 @@ describe('prepareGroupEnvironment codex auth handling', () => {
expect(segments).toEqual(['platform prompt']);
});
});
describe('prepareContainerSessionEnvironment codex compatibility', () => {
let tempRoot: string;
let previousCwd: string;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-container-env-'));
previousCwd = process.cwd();
process.chdir(tempRoot);
process.env.EJ_TEST_ROOT = tempRoot;
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
fs.mkdirSync(process.env.EJ_TEST_HOME, { recursive: true });
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
recursive: true,
});
mockReadEnvFile.mockReset();
mockGetActiveCodexAuthPath.mockReset();
});
afterEach(() => {
process.chdir(previousCwd);
delete process.env.EJ_TEST_ROOT;
delete process.env.EJ_TEST_HOME;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('writes matching AGENTS.md and copies host codex auth/config into the container session', () => {
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
const promptsDir = path.join(tempRoot, 'prompts');
fs.mkdirSync(promptsDir, { recursive: true });
fs.writeFileSync(
path.join(process.env.EJ_TEST_HOME!, '.codex', 'auth.json'),
'{"auth_mode":"chatgpt"}\n',
);
fs.writeFileSync(
path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'),
'model = "gpt-5.4"\n',
);
const sessionDir = path.join(tempRoot, 'container-reviewer-session');
prepareContainerSessionEnvironment({
sessionDir,
chatJid: 'dc:test',
isMain: false,
memoryBriefing: 'memory briefing',
role: 'reviewer',
});
const claudeMd = fs.readFileSync(path.join(sessionDir, 'CLAUDE.md'), 'utf-8');
expect(fs.readFileSync(path.join(sessionDir, '.codex', 'AGENTS.md'), 'utf-8')).toBe(
claudeMd,
);
expect(
fs.readFileSync(path.join(sessionDir, '.codex', 'auth.json'), 'utf-8'),
).toContain('"auth_mode":"chatgpt"');
expect(
fs.readFileSync(path.join(sessionDir, '.codex', 'config.toml'), 'utf-8'),
).toContain('model = "gpt-5.4"');
});
});

View File

@@ -3,7 +3,6 @@ import os from 'os';
import path from 'path';
import {
CODEX_REVIEW_SERVICE_ID,
GROUPS_DIR,
SERVICE_ID,
SERVICE_SESSION_SCOPE,
@@ -78,6 +77,31 @@ function ensureClaudeSessionSettings(groupSessionsDir: string): void {
);
}
function syncHostCodexSessionFiles(sessionCodexDir: string): void {
const hostCodexDir = path.join(os.homedir(), '.codex');
fs.mkdirSync(sessionCodexDir, { recursive: true });
const authDst = path.join(sessionCodexDir, 'auth.json');
const rotatedAuthSrc = getActiveCodexAuthPath();
const authSrc =
rotatedAuthSrc && fs.existsSync(rotatedAuthSrc)
? rotatedAuthSrc
: path.join(hostCodexDir, 'auth.json');
if (fs.existsSync(authSrc)) {
fs.copyFileSync(authSrc, authDst);
} else if (fs.existsSync(authDst)) {
fs.unlinkSync(authDst);
}
for (const file of ['config.toml', 'config.json']) {
const src = path.join(hostCodexDir, file);
const dst = path.join(sessionCodexDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dst);
}
}
}
function buildBaseRunnerEnv(args: {
group: RegisteredGroup;
chatJid: string;
@@ -214,31 +238,8 @@ function prepareCodexSessionEnvironment(args: {
process.env.CODEX_EFFORT;
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
const hostCodexDir = path.join(os.homedir(), '.codex');
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
fs.mkdirSync(sessionCodexDir, { recursive: true });
const authDst = path.join(sessionCodexDir, 'auth.json');
// Always use OAuth auth from rotated accounts (API key auth removed)
{
const rotatedAuthSrc = getActiveCodexAuthPath();
const authSrc =
rotatedAuthSrc && fs.existsSync(rotatedAuthSrc)
? rotatedAuthSrc
: path.join(hostCodexDir, 'auth.json');
if (fs.existsSync(authSrc)) {
fs.copyFileSync(authSrc, authDst);
} else if (fs.existsSync(authDst)) {
fs.unlinkSync(authDst);
}
}
for (const file of ['config.toml', 'config.json']) {
const src = path.join(hostCodexDir, file);
const dst = path.join(sessionCodexDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dst);
}
}
syncHostCodexSessionFiles(sessionCodexDir);
const overlayPath = path.join(args.groupDir, '.codex', 'config.toml');
const sessionConfigPath = path.join(sessionCodexDir, 'config.toml');
@@ -400,10 +401,10 @@ export function prepareGroupEnvironment(
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
const isPairedRoom = hasReviewerLease(chatJid);
const effectiveLease = getEffectiveChannelLease(chatJid);
// Canonical lease state now exposes owner failover directly, so prefer the
// explicit flag over the older CODEX_REVIEW_SERVICE_ID shadow heuristic.
const useCodexReviewFailoverPromptPack =
isReviewService(SERVICE_ID) &&
effectiveLease.explicit &&
effectiveLease.owner_service_id === CODEX_REVIEW_SERVICE_ID;
isReviewService(SERVICE_ID) && effectiveLease.owner_failover_active === true;
const ownerCommonPlatformPrompt = readOptionalPromptFile(
projectRoot,
@@ -553,6 +554,12 @@ export function prepareContainerSessionEnvironment(args: {
const sessionClaudeMdPath = path.join(sessionDir, 'CLAUDE.md');
if (sessionClaudeMd) {
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
const sessionCodexDir = path.join(sessionDir, '.codex');
syncHostCodexSessionFiles(sessionCodexDir);
fs.writeFileSync(
path.join(sessionCodexDir, 'AGENTS.md'),
sessionClaudeMd + '\n',
);
logger.info(
{
sessionDir,

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
ARBITER_AGENT_TYPE,
ARBITER_SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
} from './config.js';
import {
_initTestDatabase,
createServiceHandoff,
getChannelOwnerLease,
setChannelOwnerLease,
} from './db.js';
import { resolveRoleServiceShadow } from './role-service-shadow.js';
import {
clearGlobalFailover,
getEffectiveChannelLease,
refreshChannelOwnerCache,
} from './service-routing.js';
const customArbiterEnabled =
ARBITER_AGENT_TYPE === 'codex' && ARBITER_SERVICE_ID != null;
describe.skipIf(!customArbiterEnabled)(
'custom arbiter service shadow',
() => {
beforeEach(() => {
_initTestDatabase();
clearGlobalFailover();
refreshChannelOwnerCache(true);
});
it('maps codex arbiter shadow to the configured arbiter service id', () => {
expect(resolveRoleServiceShadow('arbiter', 'codex')).toBe(
ARBITER_SERVICE_ID,
);
});
it('preserves the configured arbiter service id in explicit channel leases', () => {
setChannelOwnerLease({
chat_jid: 'dc:custom-arbiter-lease',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'codex',
});
refreshChannelOwnerCache(true);
expect(getChannelOwnerLease('dc:custom-arbiter-lease')).toMatchObject({
owner_service_id: CODEX_MAIN_SERVICE_ID,
reviewer_service_id: CLAUDE_SERVICE_ID,
arbiter_service_id: ARBITER_SERVICE_ID,
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'codex',
});
expect(getEffectiveChannelLease('dc:custom-arbiter-lease')).toMatchObject(
{
owner_service_id: CODEX_MAIN_SERVICE_ID,
reviewer_service_id: CLAUDE_SERVICE_ID,
arbiter_service_id: ARBITER_SERVICE_ID,
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'codex',
},
);
});
it('uses the configured arbiter service id for derived arbiter handoffs', () => {
const handoff = createServiceHandoff({
chat_jid: 'dc:custom-arbiter-handoff',
group_folder: 'custom-arbiter-handoff',
source_role: 'owner',
source_agent_type: 'codex',
target_role: 'arbiter',
target_agent_type: 'codex',
prompt: 'arbiter please decide',
intended_role: 'arbiter',
});
expect(handoff).toMatchObject({
source_service_id: CODEX_MAIN_SERVICE_ID,
target_service_id: ARBITER_SERVICE_ID,
source_role: 'owner',
target_role: 'arbiter',
target_agent_type: 'codex',
});
});
},
);

View File

@@ -825,6 +825,18 @@ describe('DiscordChannel', () => {
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
});
it('can be configured as an outbound-only role bot', () => {
const channel = new DiscordChannel(
'test-token',
createTestOpts(),
undefined,
'discord-review',
false,
false,
);
expect(channel.ownsJid('dc:1234567890123456')).toBe(false);
});
it('does not own WhatsApp group JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('12345@g.us')).toBe(false);

View File

@@ -24,6 +24,51 @@ import { hasReviewerLease } from '../service-routing.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions');
const DISCORD_OWNER_CHANNEL = 'discord';
const DISCORD_REVIEWER_CHANNEL = 'discord-review';
const DISCORD_ARBITER_CHANNEL = 'discord-arbiter';
const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN';
const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN';
const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN';
const DISCORD_OWNER_LEGACY_TOKEN_KEYS = [
'DISCORD_BOT_TOKEN',
'DISCORD_CLAUDE_BOT_TOKEN',
];
const DISCORD_REVIEWER_LEGACY_TOKEN_KEYS = [
'DISCORD_CODEX_BOT_TOKEN',
'DISCORD_CODEX_MAIN_BOT_TOKEN',
];
const DISCORD_ARBITER_LEGACY_TOKEN_KEYS = [
'DISCORD_REVIEW_BOT_TOKEN',
'DISCORD_CODEX_REVIEW_BOT_TOKEN',
];
function getConfiguredLegacyDiscordTokenKeys(keys: string[]): string[] {
return keys.filter((key) => !!getEnv(key));
}
function getRoleTokenOrLogMigrationError(
canonicalKey: string,
legacyKeys: string[],
role: 'owner' | 'reviewer' | 'arbiter',
): string {
const canonicalValue = getEnv(canonicalKey) || '';
if (canonicalValue) return canonicalValue;
const configuredLegacyKeys = getConfiguredLegacyDiscordTokenKeys(legacyKeys);
if (configuredLegacyKeys.length > 0) {
logger.error(
{
role,
canonicalKey,
legacyKeys: configuredLegacyKeys,
},
'Discord: legacy service-based bot token names are no longer supported; rename them to canonical role-based names',
);
}
return '';
}
/**
* Download a Discord attachment to local disk.
@@ -182,16 +227,22 @@ export class DiscordChannel implements Channel {
private typingIntervals = new Map<string, NodeJS.Timeout>();
private typingGenerations = new Map<string, number>();
private agentTypeFilter?: AgentType;
private receivesInbound: boolean;
private ownsDiscordJids: boolean;
constructor(
botToken: string,
opts: DiscordChannelOpts,
agentTypeFilter?: AgentType,
channelName?: string,
receivesInbound = true,
ownsDiscordJids = true,
) {
this.botToken = botToken;
this.opts = opts;
this.agentTypeFilter = agentTypeFilter;
this.receivesInbound = receivesInbound;
this.ownsDiscordJids = ownsDiscordJids;
if (channelName) {
this.name = channelName;
} else if (agentTypeFilter) {
@@ -212,6 +263,7 @@ export class DiscordChannel implements Channel {
this.client.on(Events.MessageCreate, async (message: Message) => {
const channelId = message.channelId;
const chatJid = `dc:${channelId}`;
if (!this.receivesInbound) return;
const isOwnBotMessage = message.author.id === this.client?.user?.id;
if (isOwnBotMessage) return;
if (message.author.bot && !hasReviewerLease(chatJid)) return;
@@ -356,7 +408,8 @@ export class DiscordChannel implements Channel {
isGroup,
);
// Only deliver full message for registered groups matching our agent type
// Only deliver full message for registered groups. Secondary role bots
// are configured as outbound-only, while the owner bot receives inbound.
const group = this.opts.registeredGroups()[chatJid];
if (!group) {
logger.debug(
@@ -544,6 +597,7 @@ export class DiscordChannel implements Channel {
ownsJid(jid: string): boolean {
if (!jid.startsWith('dc:')) return false;
if (!this.ownsDiscordJids) return false;
if (!this.agentTypeFilter) return true;
const group = this.opts.registeredGroups()[jid];
if (!group) return false;
@@ -733,32 +787,66 @@ export class DiscordChannel implements Channel {
}
}
registerChannel('discord', (opts: ChannelOpts) => {
const token = getEnv('DISCORD_BOT_TOKEN') || '';
registerChannel(DISCORD_OWNER_CHANNEL, (opts: ChannelOpts) => {
const token = getRoleTokenOrLogMigrationError(
DISCORD_OWNER_TOKEN_KEY,
DISCORD_OWNER_LEGACY_TOKEN_KEYS,
'owner',
);
if (!token) {
logger.warn('Discord: DISCORD_BOT_TOKEN not set');
logger.warn(
'Discord: DISCORD_OWNER_BOT_TOKEN not set',
);
return null;
}
return new DiscordChannel(token, opts, undefined, DISCORD_OWNER_CHANNEL);
});
registerChannel(DISCORD_REVIEWER_CHANNEL, (opts: ChannelOpts) => {
const ownerToken = getEnv(DISCORD_OWNER_TOKEN_KEY) || '';
const token = getRoleTokenOrLogMigrationError(
DISCORD_REVIEWER_TOKEN_KEY,
DISCORD_REVIEWER_LEGACY_TOKEN_KEYS,
'reviewer',
);
if (!token) return null;
if (token === ownerToken) {
logger.warn(
'Discord: reviewer bot token matches owner bot token; skipping duplicate reviewer bot login',
);
return null;
}
// If a second Codex bot token exists, this instance only handles claude-code groups
const hasCodexBot = !!getEnv('DISCORD_CODEX_BOT_TOKEN');
return new DiscordChannel(
token,
opts,
hasCodexBot ? 'claude-code' : undefined,
'discord',
undefined,
DISCORD_REVIEWER_CHANNEL,
false,
false,
);
});
// Register the secondary Codex bot channel.
registerChannel('discord-codex', (opts: ChannelOpts) => {
const token = getEnv('DISCORD_CODEX_BOT_TOKEN') || '';
registerChannel(DISCORD_ARBITER_CHANNEL, (opts: ChannelOpts) => {
const ownerToken = getEnv(DISCORD_OWNER_TOKEN_KEY) || '';
const reviewerToken = getEnv(DISCORD_REVIEWER_TOKEN_KEY) || '';
const token = getRoleTokenOrLogMigrationError(
DISCORD_ARBITER_TOKEN_KEY,
DISCORD_ARBITER_LEGACY_TOKEN_KEYS,
'arbiter',
);
if (!token) return null;
return new DiscordChannel(token, opts, 'codex', 'discord-codex');
});
// Register the review bot channel (codex agent type, separate token).
registerChannel('discord-review', (opts: ChannelOpts) => {
const token = getEnv('DISCORD_REVIEW_BOT_TOKEN') || '';
if (!token) return null;
return new DiscordChannel(token, opts, 'codex', 'discord-review');
if (token === ownerToken || token === reviewerToken) {
logger.warn(
'Discord: arbiter bot token matches another role token; skipping duplicate arbiter bot login',
);
return null;
}
return new DiscordChannel(
token,
opts,
undefined,
DISCORD_ARBITER_CHANNEL,
false,
false,
);
});

View File

@@ -110,7 +110,7 @@ export const ARBITER_AGENT_TYPE: AgentType | undefined =
? rawArbiterAgentType
: undefined;
/** Service ID for the arbiter. Re-uses codex-review bot by default when arbiter is enabled. */
/** Service ID for the arbiter. Defaults to codex-review for internal routing when arbiter is enabled. */
export const ARBITER_SERVICE_ID = ARBITER_AGENT_TYPE
? getEnv('ARBITER_SERVICE_ID') || CODEX_REVIEW_SERVICE_ID
: null;

File diff suppressed because it is too large Load Diff

1548
src/db.ts

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ import {
DATA_DIR,
IDLE_TIMEOUT,
POLL_INTERVAL,
REVIEWER_AGENT_TYPE,
SERVICE_ID,
isSessionCommandSenderAllowed,
STATUS_CHANNEL_ID,
@@ -445,10 +444,8 @@ async function main(): Promise<void> {
}
// Start subsystems (independently of connection handler)
// Resolve the reviewer channel so cron output in paired rooms is posted
// via the reviewer bot — the owner then treats it as a peer request.
const reviewerChannelName =
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
// Paired-room scheduler output goes through the reviewer bot slot.
const reviewerChannelName = 'discord-review';
const reviewerChannelForCron = findChannelByName(
channels,
reviewerChannelName,

View File

@@ -11,6 +11,8 @@ vi.mock('./available-groups.js', () => ({
}));
vi.mock('./config.js', () => ({
ARBITER_SERVICE_ID: null,
CLAUDE_SERVICE_ID: 'claude',
CODEX_MAIN_SERVICE_ID: 'codex-main',
CODEX_REVIEW_SERVICE_ID: 'codex-review',
DATA_DIR: '/tmp/ejclaw-test-data',
@@ -61,13 +63,48 @@ vi.mock('./service-routing.js', () => ({
clearGlobalFailover: vi.fn(),
getEffectiveChannelLease: vi.fn(() => ({
chat_jid: 'group@test',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: null,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
})),
resolveLeaseServiceId: vi.fn(
(
lease: {
owner_service_id: string;
reviewer_service_id: string | null;
arbiter_service_id: string | null;
owner_agent_type?: 'claude-code' | 'codex';
reviewer_agent_type?: 'claude-code' | 'codex' | null;
arbiter_agent_type?: 'claude-code' | 'codex' | null;
owner_failover_active?: boolean;
},
role: 'owner' | 'reviewer' | 'arbiter',
) => {
if (role === 'owner') {
return lease.owner_failover_active
? lease.owner_service_id
: lease.owner_agent_type === 'codex'
? 'codex-main'
: 'claude';
}
if (role === 'reviewer') {
if (lease.reviewer_agent_type === 'codex') {
return 'codex-review';
}
return lease.reviewer_service_id;
}
if (lease.arbiter_agent_type === 'codex') {
return lease.arbiter_service_id ?? 'codex-review';
}
return lease.arbiter_service_id;
},
),
}));
vi.mock('./logger.js', () => {
@@ -155,6 +192,7 @@ import * as agentRunner from './agent-runner.js';
import type { AgentOutput } from './agent-runner.js';
import * as codexTokenRotation from './codex-token-rotation.js';
import * as db from './db.js';
import { logger } from './logger.js';
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
import { runAgentForGroup } from './message-agent-executor.js';
import * as pairedExecutionContext from './paired-execution-context.js';
@@ -299,13 +337,13 @@ describe('runAgentForGroup room memory', () => {
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
roomRoleContext: {
roomRoleContext: expect.objectContaining({
serviceId: 'claude',
role: 'owner',
ownerServiceId: 'claude',
reviewerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
},
}),
}),
expect.any(Function),
undefined,
@@ -317,6 +355,9 @@ describe('runAgentForGroup room memory', () => {
const group = { ...makeGroup(), folder: 'test-group' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'claude',
reviewer_service_id: 'claude',
arbiter_service_id: null,
@@ -347,6 +388,9 @@ describe('runAgentForGroup room memory', () => {
const group = { ...makeGroup(), folder: 'test-group' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'claude',
reviewer_service_id: 'claude',
arbiter_service_id: null,
@@ -383,13 +427,13 @@ describe('runAgentForGroup room memory', () => {
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
roomRoleContext: {
roomRoleContext: expect.objectContaining({
serviceId: 'claude',
role: 'reviewer',
ownerServiceId: 'claude',
reviewerServiceId: 'claude',
failoverOwner: false,
},
}),
}),
expect.any(Function),
undefined,
@@ -401,6 +445,9 @@ describe('runAgentForGroup room memory', () => {
const group = { ...makeGroup(), folder: 'test-group' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
@@ -438,13 +485,13 @@ describe('runAgentForGroup room memory', () => {
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
roomRoleContext: {
roomRoleContext: expect.objectContaining({
serviceId: 'claude',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'claude',
failoverOwner: false,
},
}),
}),
expect.any(Function),
undefined,
@@ -460,6 +507,9 @@ describe('runAgentForGroup room memory', () => {
};
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
@@ -514,10 +564,70 @@ describe('runAgentForGroup room memory', () => {
);
});
it('does not inject reviewer model overrides into a forced codex fallback run', async () => {
const group: RegisteredGroup = {
...makeGroup(),
agentType: 'codex',
folder: 'test-group',
};
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-reviewer-failover-model',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-31T00:00:00.000Z',
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-31T00:00:00.000Z',
updated_at: '2026-03-31T00:00:00.000Z',
},
workspace: null,
envOverrides: {},
});
const config = await import('./config.js');
vi.mocked(config.getRoleModelConfig).mockReturnValue({
model: 'claude-opus-4-6',
effort: 'high',
fallbackEnabled: true,
});
await runAgentForGroup(makeDeps(), {
group,
prompt: 'please retry review with codex',
chatJid: 'group@test',
runId: 'run-forced-reviewer-codex-model',
forcedRole: 'reviewer',
forcedAgentType: 'codex',
});
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
expect.objectContaining({
agentType: 'codex',
}),
expect.any(Object),
expect.any(Function),
undefined,
{},
);
});
it('allows silent reviewer outputs', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
@@ -663,6 +773,114 @@ describe('runAgentForGroup room memory', () => {
});
});
it('logs streamed activity with resolved execution attribution', async () => {
const group = {
...makeGroup(),
folder: 'test-group',
workDir: '/repo/canonical',
};
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-reviewer-log',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: null,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
workspace: {
id: 'paired-task-reviewer-log:reviewer',
task_id: 'paired-task-reviewer-log',
role: 'reviewer',
workspace_dir: '/tmp/paired/reviewer',
snapshot_source_dir: '/repo/canonical',
snapshot_ref: 'HEAD',
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
envOverrides: {
EJCLAW_WORK_DIR: '/tmp/paired/reviewer',
EJCLAW_PAIRED_TASK_ID: 'paired-task-reviewer-log',
EJCLAW_PAIRED_ROLE: 'reviewer',
},
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: 'apply_patch setup/service.ts',
phase: 'progress',
newSessionId: 'session-progress-1',
});
await onOutput?.({
status: 'success',
result: 'DONE\nreview complete',
output: { visibility: 'public', text: 'DONE\nreview complete' },
phase: 'final',
newSessionId: 'session-final-1',
});
return {
status: 'success',
result: 'DONE\nreview complete',
newSessionId: 'session-final-1',
};
},
);
await runAgentForGroup(makeDeps(), {
group,
prompt: 'please review this change',
chatJid: 'group@test',
runId: 'run-attribution-log',
forcedRole: 'reviewer',
onOutput: async () => {},
});
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'claude',
outputPhase: 'progress',
outputStatus: 'success',
activeRole: 'reviewer',
effectiveAgentType: 'claude-code',
roomRoleServiceId: 'claude',
roomRole: 'reviewer',
pairedTaskId: 'paired-task-reviewer-log',
workspaceDir: '/tmp/paired/reviewer',
preview: 'apply_patch setup/service.ts',
streamedSessionId: 'session-progress-1',
}),
'Observed streamed agent activity',
);
});
it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => {
const group = {
...makeGroup(),
@@ -673,6 +891,9 @@ describe('runAgentForGroup room memory', () => {
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
@@ -759,6 +980,9 @@ describe('runAgentForGroup room memory', () => {
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: null,
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: null,
@@ -1011,7 +1235,7 @@ describe('runAgentForGroup Claude rotation', () => {
expect(outputs).toEqual(['fresh Claude retry success']);
});
it('returns error when the fresh Claude retry also hits the same retryable thinking 400', async () => {
it('hands off to codex when the fresh Claude retry also hits the same retryable thinking 400', async () => {
const outputs: string[] = [];
const deps = makeDeps();
@@ -1055,10 +1279,25 @@ describe('runAgentForGroup Claude rotation', () => {
},
});
expect(result).toBe('error');
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(deps.clearSession).toHaveBeenCalledTimes(2);
expect(outputs).toEqual([]);
expect(serviceRouting.activateCodexFailover).toHaveBeenCalledWith(
'group@test',
'claude-session-failure',
);
expect(db.createServiceHandoff).toHaveBeenCalledWith(
expect.objectContaining({
chat_jid: 'group@test',
source_role: 'owner',
target_role: 'owner',
source_agent_type: 'claude-code',
target_agent_type: 'codex',
reason: 'claude-session-failure',
intended_role: 'owner',
}),
);
});
it('returns error after all Claude accounts are usage-exhausted', async () => {
@@ -1117,7 +1356,9 @@ describe('runAgentForGroup Claude rotation', () => {
expect(db.createServiceHandoff).toHaveBeenCalledWith(
expect.objectContaining({
chat_jid: 'group@test',
target_service_id: 'codex-review',
source_role: 'owner',
source_agent_type: 'claude-code',
target_role: 'owner',
target_agent_type: 'codex',
start_seq: 10,
end_seq: 12,
@@ -1127,6 +1368,57 @@ describe('runAgentForGroup Claude rotation', () => {
);
});
it('hands off to codex after repeated retryable Claude session failures', async () => {
vi.mocked(
sessionRecovery.shouldRetryFreshSessionOnAgentFailure,
).mockReturnValue(true);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: null,
});
return {
status: 'success',
result: null,
};
},
);
const deps = makeDeps();
const result = await runAgentForGroup(deps, {
group: makeGroup(),
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-session-failure-handoff',
startSeq: 3,
endSeq: 7,
onOutput: async () => {},
});
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2);
expect(deps.clearSession).toHaveBeenCalledTimes(2);
expect(serviceRouting.activateCodexFailover).toHaveBeenCalledWith(
'group@test',
'claude-session-failure',
);
expect(db.createServiceHandoff).toHaveBeenCalledWith(
expect.objectContaining({
chat_jid: 'group@test',
source_role: 'owner',
target_role: 'owner',
source_agent_type: 'claude-code',
target_agent_type: 'codex',
start_seq: 3,
end_seq: 7,
reason: 'claude-session-failure',
intended_role: 'owner',
}),
);
});
it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => {
const outputs: string[] = [];
@@ -1322,7 +1614,9 @@ describe('runAgentForGroup Claude rotation', () => {
expect(db.createServiceHandoff).toHaveBeenCalledWith(
expect.objectContaining({
chat_jid: 'group@test',
target_service_id: 'codex-review',
source_role: 'owner',
source_agent_type: 'claude-code',
target_role: 'owner',
target_agent_type: 'codex',
reason: 'claude-org-access-denied',
intended_role: 'owner',

View File

@@ -58,6 +58,7 @@ import { readArbiterPrompt } from './platform-prompts.js';
import {
activateCodexFailover,
getEffectiveChannelLease,
resolveLeaseServiceId,
} from './service-routing.js';
import {
evaluateStreamedOutput,
@@ -108,26 +109,26 @@ export async function runAgentForGroup(
// In unified mode, determine role from the lease directly.
// Default to owner; the auto-review trigger in completePairedExecutionContext
// will switch to reviewer when the task is in review_ready state.
const pairedTask = currentLease.reviewer_service_id
const pairedTask = currentLease.reviewer_agent_type
? getLatestOpenPairedTaskForChat(chatJid)
: null;
const inferredRole = resolveActiveRole(pairedTask?.status);
const canHonorForcedRole = Boolean(
args.forcedRole === 'owner' ||
(args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) ||
(args.forcedRole === 'arbiter' && currentLease.arbiter_service_id),
(args.forcedRole === 'reviewer' && currentLease.reviewer_agent_type) ||
(args.forcedRole === 'arbiter' && currentLease.arbiter_agent_type),
);
const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole;
const effectiveServiceId =
activeRole === 'arbiter'
? currentLease.arbiter_service_id!
: activeRole === 'reviewer'
? currentLease.reviewer_service_id!
: currentLease.owner_service_id;
const effectiveServiceId = resolveLeaseServiceId(currentLease, activeRole);
if (!effectiveServiceId) {
throw new Error(`Missing runtime service id for ${activeRole} lease`);
}
const reviewerMode = activeRole === 'reviewer';
const arbiterMode = activeRole === 'arbiter';
const reviewerServiceId = resolveLeaseServiceId(currentLease, 'reviewer');
const arbiterServiceId = resolveLeaseServiceId(currentLease, 'arbiter');
const roleAgentPlan = resolveConfiguredRoleAgentPlan(
currentLease.reviewer_service_id != null,
currentLease.reviewer_agent_type != null,
group.agentType,
);
@@ -194,8 +195,10 @@ export async function runAgentForGroup(
roomRoleContext,
hasHumanMessage: args.hasHumanMessage,
});
// Inject role-specific model overrides into envOverrides
if (pairedExecutionContext) {
// Forced fallbacks run under a different agent runtime, so keep the
// fallback session on its default model/effort unless explicitly configured
// for that runtime elsewhere.
if (pairedExecutionContext && !args.forcedAgentType) {
const roleConfig = getRoleModelConfig(activeRole);
if (roleConfig.model) {
const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL';
@@ -230,8 +233,10 @@ export async function runAgentForGroup(
groupAgentType: group.agentType,
configuredReviewerAgentType: REVIEWER_AGENT_TYPE,
configuredArbiterAgentType: ARBITER_AGENT_TYPE,
reviewerServiceId: currentLease.reviewer_service_id,
arbiterServiceId: currentLease.arbiter_service_id,
reviewerServiceId,
arbiterServiceId,
reviewerAgentType: currentLease.reviewer_agent_type,
arbiterAgentType: currentLease.arbiter_agent_type,
reviewerMode,
arbiterMode,
sessionFolder,
@@ -295,7 +300,8 @@ export async function runAgentForGroup(
reason === '429' ||
reason === 'usage-exhausted' ||
reason === 'auth-expired' ||
reason === 'org-access-denied'
reason === 'org-access-denied' ||
reason === 'session-failure'
);
};
@@ -307,7 +313,7 @@ export async function runAgentForGroup(
if (!shouldHandoffToCodex(reason, sawVisibleOutput)) {
return false;
}
if (currentLease.reviewer_service_id === null) {
if (currentLease.reviewer_agent_type === null) {
return false;
}
// Per-role fallback toggle
@@ -322,8 +328,9 @@ export async function runAgentForGroup(
createServiceHandoff({
chat_jid: chatJid,
group_folder: group.folder,
source_service_id: SERVICE_SESSION_SCOPE,
target_service_id: CODEX_REVIEW_SERVICE_ID,
source_role: activeRole,
target_role: 'arbiter',
source_agent_type: effectiveAgentType,
target_agent_type: 'codex',
prompt,
start_seq: startSeq ?? null,
@@ -344,8 +351,9 @@ export async function runAgentForGroup(
createServiceHandoff({
chat_jid: chatJid,
group_folder: group.folder,
source_service_id: SERVICE_SESSION_SCOPE,
target_service_id: CODEX_REVIEW_SERVICE_ID,
source_role: activeRole,
target_role: 'reviewer',
source_agent_type: effectiveAgentType,
target_agent_type: 'codex',
prompt,
start_seq: startSeq ?? null,
@@ -364,8 +372,9 @@ export async function runAgentForGroup(
createServiceHandoff({
chat_jid: chatJid,
group_folder: group.folder,
source_service_id: SERVICE_SESSION_SCOPE,
target_service_id: CODEX_REVIEW_SERVICE_ID,
source_role: activeRole,
target_role: activeRole,
source_agent_type: effectiveAgentType,
target_agent_type: 'codex',
prompt,
start_seq: startSeq ?? null,
@@ -375,7 +384,7 @@ export async function runAgentForGroup(
});
log.warn(
{ reason },
'Claude unavailable, handed off current turn to codex-review',
'Claude unavailable, handed off current owner turn to codex fallback',
);
return true;
};
@@ -442,6 +451,41 @@ export async function runAgentForGroup(
const wrappedOnOutput = onOutput
? async (output: AgentOutput) => {
const outputPhase = output.phase ?? 'final';
const outputText = getAgentOutputText(output);
const structuredOutput = getStructuredAgentOutput(output);
if (outputPhase !== 'final') {
log.info(
{
provider,
outputPhase,
outputStatus: output.status,
visibility: structuredOutput?.visibility ?? null,
preview:
typeof outputText === 'string' && outputText.length > 0
? outputText.slice(0, 160)
: null,
errorPreview:
typeof output.error === 'string' && output.error.length > 0
? output.error.slice(0, 160)
: null,
activeRole,
effectiveServiceId,
effectiveAgentType,
sessionFolder,
resumedSession: sessionId ?? null,
streamedSessionId: output.newSessionId ?? null,
roomRoleServiceId: roomRoleContext?.serviceId ?? null,
roomRole: roomRoleContext?.role ?? null,
pairedTaskId: pairedExecutionContext?.task.id ?? null,
workspaceDir:
pairedExecutionContext?.workspace?.workspace_dir ??
group.workDir ??
null,
},
'Observed streamed agent activity',
);
}
if (
isClaudeCodeAgent &&
provider === 'claude' &&
@@ -468,7 +512,6 @@ export async function runAgentForGroup(
});
streamedState = evaluation.state;
const outputText = getAgentOutputText(output);
if (typeof outputText === 'string' && outputText.length > 0) {
pairedExecutionSummary = outputText.slice(0, 500);
pairedFullOutput = outputText;
@@ -772,7 +815,7 @@ export async function runAgentForGroup(
log.error(
'Retryable Claude session failure persisted after fresh retry',
);
return 'error';
return maybeHandoffAfterError('session-failure', primaryAttempt);
}
}

View File

@@ -66,6 +66,7 @@ vi.mock('./db.js', () => {
failServiceHandoff: vi.fn(),
getAllChats: vi.fn(() => []),
getAllTasks: vi.fn(() => []),
getAllPendingServiceHandoffs: vi.fn(() => []),
getLastHumanMessageTimestamp: vi.fn(() => null),
getLastHumanMessageContent: vi.fn(() => null),
getMessagesSince,
@@ -114,7 +115,6 @@ vi.mock('./db.js', () => {
},
),
getOpenWorkItem: vi.fn(() => undefined),
getPendingServiceHandoffs: vi.fn(() => []),
getLatestOpenPairedTaskForChat: vi.fn(() => undefined),
getPairedTurnOutputs: vi.fn(() => []),
getRecentChatMessages: vi.fn(() => []),
@@ -124,6 +124,7 @@ vi.mock('./db.js', () => {
chat_jid: input.chat_jid,
agent_type: input.agent_type || 'claude-code',
service_id: 'claude',
delivery_role: input.delivery_role ?? null,
status: 'produced',
start_seq: input.start_seq,
end_seq: input.end_seq,
@@ -151,6 +152,20 @@ vi.mock('./service-routing.js', () => ({
reason: null,
explicit: false,
})),
resolveLeaseServiceId: vi.fn(
(
lease: {
owner_service_id: string;
reviewer_service_id: string | null;
arbiter_service_id?: string | null;
},
role: 'owner' | 'reviewer' | 'arbiter',
) => {
if (role === 'owner') return lease.owner_service_id;
if (role === 'reviewer') return lease.reviewer_service_id;
return lease.arbiter_service_id ?? null;
},
),
shouldServiceProcessChat: vi.fn(() => true),
}));
@@ -185,6 +200,7 @@ import * as db from './db.js';
import { resolveGroupIpcPath } from './group-folder.js';
import {
createMessageRuntime,
resolveHandoffCursorKey,
resolveHandoffRoleOverride,
} from './message-runtime.js';
import * as config from './config.js';
@@ -202,14 +218,18 @@ function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
};
}
function makeChannel(chatJid: string): Channel {
function makeChannel(
chatJid: string,
name = 'discord',
ownsJid = true,
): Channel {
return {
name: 'discord',
name,
connect: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
sendAndTrack: vi.fn().mockResolvedValue('progress-1'),
isConnected: vi.fn(() => true),
ownsJid: vi.fn((jid: string) => jid === chatJid),
ownsJid: vi.fn((jid: string) => ownsJid && jid === chatJid),
disconnect: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
editMessage: vi.fn().mockResolvedValue(undefined),
@@ -229,30 +249,52 @@ describe('createMessageRuntime', () => {
it('prefers intended_role over reason prefixes for handoff role resolution', () => {
expect(
resolveHandoffRoleOverride({
target_role: 'arbiter',
intended_role: 'reviewer',
reason: 'reviewer-claude-429',
}),
).toBe('arbiter');
expect(
resolveHandoffRoleOverride({
target_role: null,
intended_role: 'reviewer',
reason: 'claude-429',
}),
).toBe('reviewer');
expect(
resolveHandoffRoleOverride({
target_role: null,
intended_role: null,
reason: 'arbiter-claude-429',
}),
).toBe('arbiter');
expect(
resolveHandoffRoleOverride({
target_role: null,
intended_role: null,
reason: 'reviewer-claude-usage-exhausted',
}),
).toBe('reviewer');
expect(
resolveHandoffRoleOverride({
target_role: null,
intended_role: null,
reason: 'claude-usage-exhausted',
}),
).toBeUndefined();
});
it('uses role-scoped cursor keys for reviewer and arbiter handoffs', () => {
expect(resolveHandoffCursorKey('group@test')).toBe('group@test');
expect(resolveHandoffCursorKey('group@test', 'owner')).toBe('group@test');
expect(resolveHandoffCursorKey('group@test', 'reviewer')).toBe(
'group@test:reviewer',
);
expect(resolveHandoffCursorKey('group@test', 'arbiter')).toBe(
'group@test:arbiter',
);
});
it('ignores generic failure bot messages in paired rooms', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
@@ -576,15 +618,180 @@ describe('createMessageRuntime', () => {
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
});
it('retries a stale reviewer work item when the reviewer channel is missing', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const ownerChannel = makeChannel(chatJid);
const enqueueMessageCheck = vi.fn();
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getOpenWorkItem).mockReturnValue({
id: 100,
group_folder: group.folder,
chat_jid: chatJid,
agent_type: 'codex',
service_id: 'claude',
delivery_role: 'reviewer',
status: 'delivery_retry',
start_seq: 10,
end_seq: 11,
result_payload: 'reviewer final',
delivery_attempts: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
delivered_at: null,
delivery_message_id: null,
last_error: 'missing role channel',
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-review-delivery',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 1,
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:00.000Z',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [ownerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-open-review-work-item-missing-channel',
reason: 'messages',
});
expect(result).toBe(false);
expect(db.markWorkItemDeliveryRetry).toHaveBeenCalledWith(
100,
expect.stringContaining('discord-review'),
);
expect(ownerChannel.sendMessage).not.toHaveBeenCalled();
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
expect(enqueueMessageCheck).not.toHaveBeenCalled();
});
it('retries a stale reviewer work item via the persisted delivery role even after task status changed', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const ownerChannel = makeChannel(chatJid);
const reviewerChannel = makeChannel(chatJid, 'discord-review', false);
const enqueueMessageCheck = vi.fn();
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getOpenWorkItem).mockReturnValue({
id: 101,
group_folder: group.folder,
chat_jid: chatJid,
agent_type: 'codex',
service_id: 'claude',
delivery_role: 'reviewer',
status: 'delivery_retry',
start_seq: 10,
end_seq: 11,
result_payload: 'reviewer final retry',
delivery_attempts: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
delivered_at: null,
delivery_message_id: null,
last_error: 'discord send failed',
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-review-delivery-role',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 1,
status: 'merge_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:00.000Z',
});
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [ownerChannel, reviewerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-open-review-work-item-persisted-role',
reason: 'messages',
});
expect(result).toBe(true);
expect(reviewerChannel.sendMessage).toHaveBeenCalledWith(
chatJid,
'reviewer final retry',
);
expect(ownerChannel.sendMessage).not.toHaveBeenCalled();
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
});
it('does not inject filtered raw bot finals into workspace-based review prompts', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
const channel: Channel = {
const ownerChannel: Channel = {
...makeChannel(chatJid),
isOwnMessage: vi.fn((msg) => msg.sender === 'owner-bot@test'),
};
const reviewerChannel = makeChannel(chatJid, 'discord-review', false);
vi.mocked(config.isClaudeService).mockReturnValue(false);
vi.mocked(config.isReviewService).mockReturnValue(false);
@@ -653,7 +860,7 @@ describe('createMessageRuntime', () => {
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
channels: [ownerChannel, reviewerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
@@ -678,7 +885,10 @@ describe('createMessageRuntime', () => {
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(lastAgentTimestamps[`${chatJid}:reviewer`]).toBe('41');
expect(saveState).toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, '리뷰 확인 완료');
expect(reviewerChannel.sendMessage).toHaveBeenCalledWith(
chatJid,
'리뷰 확인 완료',
);
});
it('includes the latest reviewer summary in merge_ready finalize prompts and truncates it', async () => {
@@ -789,7 +999,8 @@ describe('createMessageRuntime', () => {
it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const channel = makeChannel(chatJid);
const ownerChannel = makeChannel(chatJid);
const arbiterChannel = makeChannel(chatJid, 'discord-arbiter', false);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
@@ -876,7 +1087,7 @@ describe('createMessageRuntime', () => {
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
channels: [ownerChannel, arbiterChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
@@ -899,19 +1110,234 @@ describe('createMessageRuntime', () => {
expect(result).toBe(true);
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
expect(arbiterChannel.sendMessage).toHaveBeenCalledWith(
chatJid,
'arbiter 확인 완료',
);
});
it('does not fabricate owner labels from same-service raw bot history in paired turn prompts', async () => {
it('fails closed for pending reviewer turns when the reviewer channel is missing', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const ownerChannel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-review-pending-missing-channel',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 0,
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:00.000Z',
});
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'human-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: '리뷰해줘',
timestamp: '2026-03-30T00:00:11.000Z',
seq: 12,
is_bot_message: false,
},
] as any);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [ownerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-pending-review-missing-channel',
reason: 'messages',
});
expect(result).toBe(false);
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
expect(ownerChannel.sendMessage).not.toHaveBeenCalled();
expect(lastAgentTimestamps[`${chatJid}:reviewer`]).toBeUndefined();
expect(saveState).not.toHaveBeenCalled();
});
it('fails closed for pending arbiter turns when the arbiter channel is missing', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const ownerChannel = makeChannel(chatJid);
const lastAgentTimestamps: Record<string, string> = {};
const saveState = vi.fn();
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-arbiter-pending-missing-channel',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 3,
status: 'arbiter_requested',
arbiter_verdict: null,
arbiter_requested_at: '2026-03-30T00:00:10.000Z',
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:10.000Z',
});
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'human-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: '판정해줘',
timestamp: '2026-03-30T00:00:11.000Z',
seq: 22,
is_bot_message: false,
},
] as any);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [ownerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-pending-arbiter-missing-channel',
reason: 'messages',
});
expect(result).toBe(false);
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
expect(ownerChannel.sendMessage).not.toHaveBeenCalled();
expect(lastAgentTimestamps[`${chatJid}:arbiter`]).toBeUndefined();
expect(saveState).not.toHaveBeenCalled();
});
it('fails closed for normal arbiter turns when the arbiter channel is missing', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const ownerChannel = makeChannel(chatJid);
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-arbiter-normal-missing-channel',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:00.000Z',
round_trip_count: 3,
status: 'in_arbitration',
arbiter_verdict: null,
arbiter_requested_at: '2026-03-30T00:00:10.000Z',
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:10.000Z',
});
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'human-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: '판정해줘',
timestamp: '2026-03-30T00:00:11.000Z',
is_bot_message: false,
},
] as any);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [ownerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-normal-arbiter-missing-channel',
reason: 'messages',
});
expect(result).toBe(false);
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
expect(ownerChannel.sendMessage).not.toHaveBeenCalled();
});
it('labels raw reviewer bot history by fixed role in paired turn prompts', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
const channel: Channel = {
...makeChannel(chatJid),
const ownerChannel = makeChannel(chatJid);
const reviewerChannel: Channel = {
...makeChannel(chatJid, 'discord-review', false),
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
};
@@ -967,7 +1393,7 @@ describe('createMessageRuntime', () => {
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toContain(
'<message sender="Shared Bot" time="30 Mar 09:00">reviewer-like reply</message>',
'<message sender="reviewer" time="30 Mar 09:00">reviewer-like reply</message>',
);
expect(input.prompt).not.toContain(
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
@@ -992,7 +1418,7 @@ describe('createMessageRuntime', () => {
pollInterval: 1_000,
timezone: 'Asia/Seoul',
triggerPattern: /^@Andy\b/i,
channels: [channel],
channels: [ownerChannel, reviewerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
@@ -1009,7 +1435,7 @@ describe('createMessageRuntime', () => {
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-same-service-raw-history',
runId: 'run-fixed-reviewer-bot-history',
reason: 'messages',
});
@@ -1017,11 +1443,12 @@ describe('createMessageRuntime', () => {
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
});
it('does not fabricate owner labels from same-service raw bot history in arbiter prompts', async () => {
it('labels raw arbiter bot history by fixed role in arbiter prompts', async () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const channel: Channel = {
...makeChannel(chatJid),
const ownerChannel = makeChannel(chatJid);
const arbiterChannel: Channel = {
...makeChannel(chatJid, 'discord-arbiter', false),
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
};
@@ -1077,7 +1504,7 @@ describe('createMessageRuntime', () => {
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toContain(
'<message sender="Shared Bot" time="30 Mar 09:00">reviewer-like reply</message>',
'<message sender="arbiter" time="30 Mar 09:00">reviewer-like reply</message>',
);
expect(input.prompt).not.toContain(
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
@@ -1102,7 +1529,7 @@ describe('createMessageRuntime', () => {
pollInterval: 1_000,
timezone: 'Asia/Seoul',
triggerPattern: /^@Andy\b/i,
channels: [channel],
channels: [ownerChannel, arbiterChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
@@ -1119,7 +1546,7 @@ describe('createMessageRuntime', () => {
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-same-service-arbiter-fallback',
runId: 'run-fixed-arbiter-bot-history',
reason: 'messages',
});

View File

@@ -5,8 +5,8 @@ import {
completeServiceHandoffAndAdvanceTargetCursor,
createProducedWorkItem,
failServiceHandoff,
getAllPendingServiceHandoffs,
getOpenWorkItem,
getPendingServiceHandoffs,
getMessagesSinceSeq,
getNewMessagesBySeq,
markWorkItemDelivered,
@@ -21,12 +21,7 @@ import {
type WorkItem,
} from './db.js';
import {
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
isSessionCommandSenderAllowed,
REVIEWER_AGENT_TYPE,
SERVICE_ID,
} from './config.js';
import { GroupQueue, GroupRunContext } from './group-queue.js';
import {
@@ -67,7 +62,6 @@ import {
import { createScopedLogger, logger } from './logger.js';
import { resolveGroupIpcPath } from './group-folder.js';
import {
getEffectiveChannelLease,
hasReviewerLease,
} from './service-routing.js';
@@ -97,8 +91,11 @@ export function isDuplicateOfLastBotFinal(
}
export function resolveHandoffRoleOverride(
handoff: Pick<ServiceHandoff, 'intended_role' | 'reason'>,
handoff: Pick<ServiceHandoff, 'target_role' | 'intended_role' | 'reason'>,
): PairedRoomRole | undefined {
if (handoff.target_role) {
return handoff.target_role;
}
if (handoff.intended_role) {
return handoff.intended_role;
}
@@ -111,6 +108,16 @@ export function resolveHandoffRoleOverride(
return undefined;
}
export function resolveHandoffCursorKey(
chatJid: string,
role?: PairedRoomRole,
): string {
if (!role || role === 'owner') {
return chatJid;
}
return `${chatJid}:${role}`;
}
export interface MessageRuntimeDeps {
assistantName: string;
idleTimeout: number;
@@ -129,6 +136,14 @@ export interface MessageRuntimeDeps {
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
}
function getFixedRoleChannelName(role: 'reviewer' | 'arbiter'): string {
return role === 'reviewer' ? 'discord-review' : 'discord-arbiter';
}
function getMissingRoleChannelMessage(role: 'reviewer' | 'arbiter'): string {
return `Missing configured ${role} Discord bot channel (${getFixedRoleChannelName(role)}) for role-fixed delivery`;
}
export function createMessageRuntime(deps: MessageRuntimeDeps): {
processGroupMessages: (
chatJid: string,
@@ -149,10 +164,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
messages: NewMessage[],
): NewMessage[] => {
if (!hasReviewerLease(chatJid)) return messages;
const lease = getEffectiveChannelLease(chatJid);
const sharedOwnerReviewerService =
lease.reviewer_service_id !== null &&
lease.owner_service_id === lease.reviewer_service_id;
// Build bot-user-id → channel-name mapping from connected channels
const botIdToChannelName = new Map<string, string>();
for (const ch of deps.channels) {
@@ -164,36 +175,17 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}
}
}
// Map channel name → service ID
const channelToService: Record<string, string> = {
discord: 'claude',
'discord-codex': 'codex-main',
'discord-review': 'codex-review',
const channelToRole: Record<string, PairedRoomRole> = {
discord: 'owner',
'discord-review': 'reviewer',
'discord-arbiter': 'arbiter',
};
return messages.map((msg) => {
if (!msg.is_bot_message) return msg;
const channelName = botIdToChannelName.get(msg.sender);
if (!channelName) return msg;
const serviceId = channelToService[channelName];
if (!serviceId) return msg;
// Raw channel history cannot tell owner/reviewer apart when both roles
// are delivered by the same service, so avoid fabricating a role label.
if (
sharedOwnerReviewerService &&
serviceId === lease.owner_service_id &&
serviceId === lease.reviewer_service_id
) {
return msg;
}
const role =
serviceId === lease.owner_service_id
? 'owner'
: serviceId === lease.reviewer_service_id
? 'reviewer'
: serviceId === lease.arbiter_service_id
? 'arbiter'
: msg.sender_name;
return { ...msg, sender_name: role };
const role = channelToRole[channelName];
return role ? { ...msg, sender_name: role } : msg;
});
};
@@ -467,6 +459,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
channel: Channel;
startSeq: number | null;
endSeq: number | null;
deliveryRole?: PairedRoomRole;
hasHumanMessage?: boolean;
forcedRole?: PairedRoomRole;
forcedAgentType?: AgentType;
@@ -504,6 +497,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chat_jid: chatJid,
agent_type:
args.forcedAgentType ?? group.agentType ?? 'claude-code',
delivery_role: args.deliveryRole ?? args.forcedRole ?? null,
start_seq: startSeq,
end_seq: endSeq,
result_payload: text,
@@ -563,17 +557,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
};
const enqueuePendingHandoffs = (): void => {
// Unified service handles all three bots — collect handoffs for all service IDs.
const allServiceIds = new Set([
SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
]);
const allHandoffs = [...allServiceIds].flatMap((id) =>
getPendingServiceHandoffs(id),
);
for (const handoff of allHandoffs) {
// Unified runtime claims pending handoffs once and resolves delivery from role context.
for (const handoff of getAllPendingServiceHandoffs()) {
if (!claimServiceHandoff(handoff.id)) {
continue;
}
@@ -608,14 +593,29 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const handoffRole = resolveHandoffRoleOverride(handoff);
let handoffChannel = channel;
if (handoffRole === 'reviewer') {
const revChName =
handoff.target_agent_type === 'claude-code'
? 'discord'
: 'discord-review';
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
// Role-fixed delivery intentionally does not follow fallback agent type.
const reviewerChannel = findChannelByName(
deps.channels,
getFixedRoleChannelName('reviewer'),
);
if (!reviewerChannel) {
failServiceHandoff(
handoff.id,
getMissingRoleChannelMessage('reviewer'),
);
return;
}
handoffChannel = reviewerChannel;
} else if (handoffRole === 'arbiter') {
handoffChannel =
findChannelByName(deps.channels, 'discord-review') || channel;
const arbiterChannel = findChannelByName(
deps.channels,
getFixedRoleChannelName('arbiter'),
);
if (!arbiterChannel) {
failServiceHandoff(handoff.id, getMissingRoleChannelMessage('arbiter'));
return;
}
handoffChannel = arbiterChannel;
}
const runId = `handoff-${handoff.id}`;
@@ -626,6 +626,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
handoffId: handoff.id,
runId,
handoffRole,
targetRole: handoff.target_role ?? null,
targetServiceId: handoff.target_service_id,
targetAgentType: handoff.target_agent_type,
reason: handoff.reason,
@@ -651,14 +652,16 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return;
}
const cursorKey = resolveHandoffCursorKey(handoff.chat_jid, handoffRole);
const appliedCursor = completeServiceHandoffAndAdvanceTargetCursor({
id: handoff.id,
target_service_id: handoff.target_service_id,
chat_jid: handoff.chat_jid,
cursor_key: cursorKey,
end_seq: handoff.end_seq,
});
if (appliedCursor) {
deps.getLastAgentTimestamps()[handoff.chat_jid] = appliedCursor;
deps.getLastAgentTimestamps()[cursorKey] = appliedCursor;
deps.saveState();
}
logger.info(
{
@@ -668,6 +671,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
outputStatus: result.outputStatus,
visiblePhase: result.visiblePhase,
appliedCursor,
cursorKey:
appliedCursor != null
? resolveHandoffCursorKey(handoff.chat_jid, handoffRole)
: null,
},
'Completed claimed service handoff',
);
@@ -701,44 +708,39 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return true;
}
// For paired rooms, determine the reviewer channel for correct bot routing.
// This is used whenever the reviewer needs to send output.
const reviewerChannelName =
hasReviewerLease(chatJid) && REVIEWER_AGENT_TYPE === 'claude-code'
? 'discord'
: 'discord-review';
// For paired rooms, reviewer/arbiter outputs use their fixed role bots.
const reviewerChannelName = 'discord-review';
const foundReviewerChannel = findChannelByName(
deps.channels,
reviewerChannelName,
);
const reviewerChannel = foundReviewerChannel || channel;
// Arbiter always uses discord-review bot regardless of model —
// the arbiter role is tied to the 3rd bot, not the model behind it.
const arbiterChannelName = 'discord-review';
const arbiterChannelName = 'discord-arbiter';
const foundArbiterChannel = findChannelByName(
deps.channels,
arbiterChannelName,
);
const arbiterChannel = foundArbiterChannel || channel;
// Resolve the correct Discord channel for a given task status.
const roleToChannel: Record<string, Channel> = {
const roleToChannel: Record<string, Channel | null> = {
owner: channel,
reviewer: reviewerChannel,
arbiter: arbiterChannel,
reviewer: foundReviewerChannel || null,
arbiter: foundArbiterChannel || null,
};
const resolveChannel = (taskStatus?: string | null): Channel | null => {
const role = resolveActiveRole(taskStatus);
return role === 'owner' ? channel : roleToChannel[role];
};
const resolveChannel = (taskStatus?: string | null): Channel =>
roleToChannel[resolveActiveRole(taskStatus)] ?? channel;
const buildPendingPairedTurn = (
task: PairedTask,
rawMissedMessages: NewMessage[],
): {
prompt: string;
channel: Channel;
channel: Channel | null;
cursor: string | number | null;
cursorKey?: string;
role?: 'reviewer' | 'arbiter';
} | null => {
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
const cursor = lastRaw?.seq ?? lastRaw?.timestamp ?? null;
@@ -751,6 +753,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
channel: resolveChannel(taskStatus),
cursor,
cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'reviewer',
};
}
@@ -760,6 +763,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
channel: resolveChannel(taskStatus),
cursor,
cursorKey: resolveCursorKey(chatJid, taskStatus),
role: 'arbiter',
};
}
@@ -776,10 +780,23 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const executePendingPairedTurn = async (args: {
prompt: string;
channel: Channel;
channel: Channel | null;
cursor: string | number | null;
cursorKey?: string;
role?: 'reviewer' | 'arbiter';
}): Promise<boolean> => {
if (!args.channel) {
const missingRole = args.role ?? 'reviewer';
log.error(
{
role: missingRole,
requiredChannel: getFixedRoleChannelName(missingRole),
},
'Skipping paired turn because the dedicated Discord role channel is not configured',
);
return false;
}
if (args.cursor != null) {
advanceLastAgentCursor(
deps.getLastAgentTimestamps(),
@@ -796,6 +813,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid,
runId,
channel: args.channel,
deliveryRole: args.role,
startSeq: null,
endSeq: null,
});
@@ -808,10 +826,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
{
reviewerChannelName,
foundChannel: foundReviewerChannel?.name ?? null,
usingChannel: reviewerChannel.name,
arbiterChannelName,
foundArbiterChannel: foundArbiterChannel?.name ?? null,
usingArbiterChannel: arbiterChannel.name,
availableChannels: deps.channels.map((c) => c.name),
},
'Paired room reviewer/arbiter channel resolution',
@@ -828,7 +844,25 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
const pendingTask = hasReviewerLease(chatJid)
? getLatestOpenPairedTaskForChat(chatJid)
: null;
const deliveryChannel = resolveChannel(pendingTask?.status);
const deliveryRole =
openWorkItem.delivery_role ??
(pendingTask ? resolveActiveRole(pendingTask.status) : 'owner');
const deliveryChannel =
deliveryRole === 'owner' ? channel : roleToChannel[deliveryRole];
if (!deliveryChannel) {
const missingRole = deliveryRole === 'arbiter' ? 'arbiter' : 'reviewer';
const errorMessage = getMissingRoleChannelMessage(missingRole);
markWorkItemDeliveryRetry(openWorkItem.id, errorMessage);
log.error(
{
workItemId: openWorkItem.id,
role: deliveryRole,
requiredChannel: getFixedRoleChannelName(missingRole),
},
'Unable to deliver paired-room work item because the dedicated Discord role channel is not configured',
);
return false;
}
const delivered = await deliverOpenWorkItem(
deliveryChannel,
openWorkItem,
@@ -999,6 +1033,28 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
}
const startSeq = missedMessages[0].seq ?? null;
const endSeq = missedMessages[missedMessages.length - 1].seq ?? null;
log.info(
{
messageCount: missedMessages.length,
messageSeqStart: startSeq,
messageSeqEnd: endSeq,
},
'Dispatching queued messages to agent',
);
if (!turnChannel) {
const missingRole = turnRole === 'arbiter' ? 'arbiter' : 'reviewer';
log.error(
{
taskStatus,
role: turnRole,
requiredChannel: getFixedRoleChannelName(missingRole),
},
'Skipping paired-room run because the dedicated Discord role channel is not configured',
);
return false;
}
if (endSeq !== null) {
advanceLastAgentCursor(
deps.getLastAgentTimestamps(),
@@ -1009,15 +1065,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
);
}
log.info(
{
messageCount: missedMessages.length,
messageSeqStart: startSeq,
messageSeqEnd: endSeq,
},
'Dispatching queued messages to agent',
);
const hasHumanMsg = !isBotOnlyPairedRoomTurn(chatJid, missedMessages);
const { deliverySucceeded, visiblePhase } = await executeTurn({
group,
@@ -1025,6 +1072,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid,
runId,
channel: turnChannel,
deliveryRole: pendingTaskForChannel ? turnRole : undefined,
startSeq,
endSeq,
hasHumanMessage: hasHumanMsg,

View File

@@ -55,21 +55,29 @@ const group: RegisteredGroup = {
};
const ownerContext: RoomRoleContext = {
serviceId: 'codex-main',
serviceId: config.CODEX_MAIN_SERVICE_ID,
role: 'owner',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
ownerServiceId: config.CODEX_MAIN_SERVICE_ID,
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
failoverOwner: false,
};
const reviewerContext: RoomRoleContext = {
serviceId: 'codex-review',
serviceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
ownerServiceId: config.CODEX_MAIN_SERVICE_ID,
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
failoverOwner: false,
};
const failoverOwnerContext: RoomRoleContext = {
serviceId: config.CODEX_REVIEW_SERVICE_ID,
role: 'owner',
ownerServiceId: config.CODEX_REVIEW_SERVICE_ID,
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
failoverOwner: true,
};
function createCanonicalRepoWithCommit(commitMessage: string): string {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
@@ -103,8 +111,8 @@ function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
title: null,
source_ref: 'HEAD',
plan_notes: null,
@@ -181,7 +189,12 @@ describe('paired execution context', () => {
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(db.createPairedTask).toHaveBeenCalledWith(
expect.objectContaining({
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
status: 'active',
owner_agent_type: 'codex',
reviewer_agent_type: config.REVIEWER_AGENT_TYPE,
arbiter_agent_type: config.ARBITER_AGENT_TYPE ?? null,
}),
);
expect(result?.envOverrides).toMatchObject({
@@ -190,6 +203,25 @@ describe('paired execution context', () => {
});
});
it('persists stable role-slot service shadow instead of the transient failover owner lease', () => {
preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-failover-owner',
roomRoleContext: failoverOwnerContext,
hasHumanMessage: true,
});
expect(db.createPairedTask).toHaveBeenCalledWith(
expect.objectContaining({
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
owner_agent_type: 'codex',
reviewer_agent_type: config.REVIEWER_AGENT_TYPE,
}),
);
});
it('uses the reviewer snapshot after lazy auto-refresh and marks the task in_review', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',

View File

@@ -5,8 +5,13 @@ import path from 'path';
import {
ARBITER_DEADLOCK_THRESHOLD,
ARBITER_AGENT_TYPE,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
DATA_DIR,
PAIRED_MAX_ROUND_TRIPS,
REVIEWER_AGENT_TYPE,
isArbiterEnabled,
} from './config.js';
import {
@@ -26,12 +31,14 @@ import {
provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js';
import type {
AgentType,
PairedRoomRole,
PairedTask,
PairedWorkspace,
RegisteredGroup,
RoomRoleContext,
} from './types.js';
import { resolveRoleAgentPlan } from './role-agent-plan.js';
// ---------------------------------------------------------------------------
// Reviewer verdict detection
@@ -158,6 +165,21 @@ function ensurePairedProject(
return group.workDir;
}
function resolvePairedTaskServiceShadow(
role: 'owner' | 'reviewer',
agentType: AgentType | null | undefined,
): string | null {
if (!agentType) {
return null;
}
if (agentType === 'claude-code') {
return CLAUDE_SERVICE_ID;
}
return role === 'owner' ? CODEX_MAIN_SERVICE_ID : CODEX_REVIEW_SERVICE_ID;
}
// ---------------------------------------------------------------------------
// ensureActiveTask
// ---------------------------------------------------------------------------
@@ -185,12 +207,29 @@ function ensureActiveTask(
}
const now = new Date().toISOString();
const rolePlan = resolveRoleAgentPlan({
paired: true,
groupAgentType: group.agentType,
configuredReviewer: REVIEWER_AGENT_TYPE,
configuredArbiter: ARBITER_AGENT_TYPE,
});
const ownerServiceShadow = resolvePairedTaskServiceShadow(
'owner',
rolePlan.ownerAgentType,
)!;
const reviewerServiceShadow = resolvePairedTaskServiceShadow(
'reviewer',
rolePlan.reviewerAgentType,
)!;
const task: PairedTask = {
id: crypto.randomUUID(),
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: roomRoleContext.ownerServiceId,
reviewer_service_id: roomRoleContext.reviewerServiceId,
owner_service_id: ownerServiceShadow,
reviewer_service_id: reviewerServiceShadow,
owner_agent_type: rolePlan.ownerAgentType,
reviewer_agent_type: rolePlan.reviewerAgentType,
arbiter_agent_type: rolePlan.arbiterAgentType,
title: null,
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
plan_notes: null,

View File

@@ -18,6 +18,79 @@ async function loadModules() {
return { db, manager };
}
const FIXTURE_NOW = '2026-03-28T00:00:00.000Z';
function initCanonicalRepo(
repoDir: string,
files: Record<string, string> = { 'README.md': 'original\n' },
): void {
fs.mkdirSync(repoDir, { recursive: true });
runGit(['init'], repoDir);
runGit(['config', 'user.email', 'test@example.com'], repoDir);
runGit(['config', 'user.name', 'EJClaw Test'], repoDir);
for (const [relativePath, contents] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(repoDir, relativePath)), {
recursive: true,
});
fs.writeFileSync(path.join(repoDir, relativePath), contents);
}
runGit(['add', '.'], repoDir);
runGit(['commit', '-m', 'initial'], repoDir);
}
function seedPairedTask(
db: Awaited<ReturnType<typeof loadModules>>['db'],
canonicalDir: string,
args: {
taskId: string;
groupFolder?: string;
chatJid?: string;
sourceRef?: string;
status?: 'active' | 'review_ready' | 'in_review' | 'merge_ready';
reviewRequestedAt?: string | null;
updatedAt?: string;
},
): void {
const groupFolder = args.groupFolder ?? 'paired-room';
const chatJid = args.chatJid ?? 'dc:test';
const reviewRequestedAt = args.reviewRequestedAt ?? null;
const updatedAt = args.updatedAt ?? FIXTURE_NOW;
db.upsertPairedProject({
chat_jid: chatJid,
group_folder: groupFolder,
canonical_work_dir: canonicalDir,
created_at: FIXTURE_NOW,
updated_at: FIXTURE_NOW,
});
db.createPairedTask({
id: args.taskId,
chat_jid: chatJid,
group_folder: groupFolder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: args.sourceRef ?? 'HEAD',
plan_notes: null,
round_trip_count: 0,
review_requested_at: reviewRequestedAt,
status: args.status ?? 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: FIXTURE_NOW,
updated_at: updatedAt,
});
}
function ownerBranchName(groupFolder: string): string {
return `codex/owner/${groupFolder}`;
}
function ownerWorkspacePath(groupFolder: string): string {
return path.join(process.env.EJCLAW_DATA_DIR!, 'workspaces', groupFolder, 'owner');
}
describe('paired workspace manager', () => {
let tempRoot: string;
let previousDataDir: string | undefined;
@@ -90,6 +163,9 @@ describe('paired workspace manager', () => {
expect(fs.existsSync(path.join(ownerWorkspace.workspace_dir, '.git'))).toBe(
true,
);
expect(
runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir),
).toBe(ownerBranchName('paired-room'));
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
@@ -647,4 +723,204 @@ describe('paired workspace manager', () => {
),
).toBe('owner changed again\n');
});
it('bases a new owner branch on the canonical repo HEAD without requiring a remote', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
initCanonicalRepo(canonicalDir);
runGit(['checkout', '-b', 'feature/base'], canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'feature base\n');
runGit(['commit', '-am', 'feature base'], canonicalDir);
seedPairedTask(db, canonicalDir, {
taskId: 'paired-task-head-base',
groupFolder: 'head-base-room',
});
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-head-base');
expect(
runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir),
).toBe(ownerBranchName('head-base-room'));
expect(
fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'README.md'), 'utf-8'),
).toBe('feature base\n');
expect(runGit(['rev-parse', 'HEAD'], ownerWorkspace.workspace_dir)).toBe(
runGit(['rev-parse', 'HEAD'], canonicalDir),
);
});
it('provisions and reprovisions owner workspaces when source_ref is a tree hash', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
initCanonicalRepo(canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'tree source\n');
runGit(['commit', '-am', 'tree source'], canonicalDir);
const treeSourceRef = runGit(['rev-parse', 'HEAD^{tree}'], canonicalDir);
seedPairedTask(db, canonicalDir, {
taskId: 'paired-task-tree-source-ref',
groupFolder: 'tree-source-room',
sourceRef: treeSourceRef,
});
const firstProvision = manager.provisionOwnerWorkspaceForPairedTask(
'paired-task-tree-source-ref',
);
const secondProvision = manager.provisionOwnerWorkspaceForPairedTask(
'paired-task-tree-source-ref',
);
expect(firstProvision.workspace_dir).toBe(secondProvision.workspace_dir);
expect(
runGit(['branch', '--show-current'], secondProvision.workspace_dir),
).toBe(ownerBranchName('tree-source-room'));
expect(
fs.readFileSync(path.join(secondProvision.workspace_dir, 'README.md'), 'utf-8'),
).toBe('tree source\n');
expect(runGit(['rev-parse', 'HEAD'], secondProvision.workspace_dir)).toBe(
runGit(['rev-parse', 'HEAD'], canonicalDir),
);
});
it('repairs a missing but registered owner worktree path before reprovisioning', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
initCanonicalRepo(canonicalDir);
seedPairedTask(db, canonicalDir, {
taskId: 'paired-task-repair',
groupFolder: 'repair-room',
});
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-repair');
fs.rmSync(ownerWorkspace.workspace_dir, { recursive: true, force: true });
expect(runGit(['worktree', 'list', '--porcelain'], canonicalDir)).toContain(
ownerWorkspace.workspace_dir,
);
const reprovisioned =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-repair');
expect(reprovisioned.workspace_dir).toBe(ownerWorkspace.workspace_dir);
expect(
runGit(['branch', '--show-current'], reprovisioned.workspace_dir),
).toBe(ownerBranchName('repair-room'));
expect(
runGit(['worktree', 'list', '--porcelain'], canonicalDir),
).toContain(reprovisioned.workspace_dir);
});
it('lazy-migrates a detached dirty owner workspace to a new channel branch', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
initCanonicalRepo(canonicalDir);
seedPairedTask(db, canonicalDir, {
taskId: 'paired-task-migrate-new-branch',
groupFolder: 'migrate-room',
});
const workspaceDir = ownerWorkspacePath('migrate-room');
fs.mkdirSync(path.dirname(workspaceDir), { recursive: true });
runGit(['worktree', 'add', workspaceDir, 'HEAD'], canonicalDir);
fs.writeFileSync(path.join(workspaceDir, 'README.md'), 'detached dirty\n');
fs.writeFileSync(path.join(workspaceDir, 'NEW_FILE.txt'), 'new file\n');
const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask(
'paired-task-migrate-new-branch',
);
expect(
runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir),
).toBe(ownerBranchName('migrate-room'));
expect(
fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'README.md'), 'utf-8'),
).toBe('detached dirty\n');
expect(
fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'NEW_FILE.txt'), 'utf-8'),
).toBe('new file\n');
expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toContain(
'M README.md',
);
expect(runGit(['status', '--short'], ownerWorkspace.workspace_dir)).toContain(
'?? NEW_FILE.txt',
);
});
it('lazy-migrates a detached dirty owner workspace onto an existing matching channel branch', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
initCanonicalRepo(canonicalDir);
seedPairedTask(db, canonicalDir, {
taskId: 'paired-task-migrate-existing-branch',
groupFolder: 'migrate-existing-room',
});
runGit(
['branch', ownerBranchName('migrate-existing-room'), 'HEAD'],
canonicalDir,
);
const workspaceDir = ownerWorkspacePath('migrate-existing-room');
fs.mkdirSync(path.dirname(workspaceDir), { recursive: true });
runGit(['worktree', 'add', workspaceDir, 'HEAD'], canonicalDir);
fs.writeFileSync(path.join(workspaceDir, 'README.md'), 'detached existing\n');
fs.writeFileSync(path.join(workspaceDir, 'NOTES.md'), 'keep me\n');
const ownerWorkspace = manager.provisionOwnerWorkspaceForPairedTask(
'paired-task-migrate-existing-branch',
);
expect(
runGit(['branch', '--show-current'], ownerWorkspace.workspace_dir),
).toBe(ownerBranchName('migrate-existing-room'));
expect(
fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'README.md'), 'utf-8'),
).toBe('detached existing\n');
expect(
fs.readFileSync(path.join(ownerWorkspace.workspace_dir, 'NOTES.md'), 'utf-8'),
).toBe('keep me\n');
});
it('blocks provisioning when the channel branch is already checked out in another worktree', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
initCanonicalRepo(canonicalDir);
seedPairedTask(db, canonicalDir, {
taskId: 'paired-task-branch-collision',
groupFolder: 'collision-room',
});
const conflictingDir = path.join(tempRoot, 'conflicting-owner');
runGit(
[
'worktree',
'add',
'-b',
ownerBranchName('collision-room'),
conflictingDir,
'HEAD',
],
canonicalDir,
);
expect(() =>
manager.provisionOwnerWorkspaceForPairedTask(
'paired-task-branch-collision',
),
).toThrow(/already checked out/i);
});
});

View File

@@ -121,6 +121,144 @@ function ensureCleanDirectory(targetDir: string): void {
fs.mkdirSync(targetDir, { recursive: true });
}
type GitWorktreeEntry = {
worktreePath: string;
head: string | null;
branchRef: string | null;
detached: boolean;
prunableReason: string | null;
};
function tryRunGit(args: string[], cwd?: string): string | null {
try {
return runGit(args, cwd);
} catch {
return null;
}
}
function listGitWorktrees(repoDir: string): GitWorktreeEntry[] {
const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
cwd: repoDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const entries: GitWorktreeEntry[] = [];
let current: GitWorktreeEntry | null = null;
const pushCurrent = () => {
if (current) {
entries.push(current);
current = null;
}
};
for (const rawLine of output.split('\n')) {
const line = rawLine.trimEnd();
if (line.length === 0) {
pushCurrent();
continue;
}
if (line.startsWith('worktree ')) {
pushCurrent();
current = {
worktreePath: path.resolve(line.slice('worktree '.length).trim()),
head: null,
branchRef: null,
detached: false,
prunableReason: null,
};
continue;
}
if (!current) {
continue;
}
if (line.startsWith('HEAD ')) {
current.head = line.slice('HEAD '.length).trim();
continue;
}
if (line.startsWith('branch ')) {
current.branchRef = line.slice('branch '.length).trim();
continue;
}
if (line === 'detached') {
current.detached = true;
continue;
}
if (line.startsWith('prunable')) {
current.prunableReason = line.slice('prunable'.length).trim() || null;
}
}
pushCurrent();
return entries;
}
function branchRefName(branchName: string): string {
return `refs/heads/${branchName}`;
}
function buildOwnerBranchName(groupFolder: string): string {
return `codex/owner/${groupFolder}`;
}
function resolveBranchName(repoDir: string): string | null {
return tryRunGit(['symbolic-ref', '--short', '-q', 'HEAD'], repoDir);
}
function resolveCommit(repoDir: string, ref: string): string | null {
return tryRunGit(['rev-parse', '--verify', `${ref}^{commit}`], repoDir);
}
function findWorktreeEntry(
repoDir: string,
workspaceDir: string,
): GitWorktreeEntry | null {
const resolvedWorkspaceDir = path.resolve(workspaceDir);
return (
listGitWorktrees(repoDir).find(
(entry) => path.resolve(entry.worktreePath) === resolvedWorkspaceDir,
) ?? null
);
}
function ensureBranchNotCheckedOutElsewhere(
canonicalWorkDir: string,
workspaceDir: string,
branchName: string,
): void {
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const targetBranchRef = branchRefName(branchName);
const conflictingWorktree = listGitWorktrees(canonicalWorkDir).find(
(entry) =>
entry.branchRef === targetBranchRef &&
path.resolve(entry.worktreePath) !== resolvedWorkspaceDir,
);
if (conflictingWorktree) {
throw new Error(
`Owner branch ${branchName} is already checked out at ${conflictingWorktree.worktreePath}.`,
);
}
}
function repairOwnerWorktreeRegistration(
workspaceDir: string,
canonicalWorkDir: string,
): void {
runGit(['worktree', 'prune', '--expire', 'now'], canonicalWorkDir);
const entry = findWorktreeEntry(canonicalWorkDir, workspaceDir);
if (!entry) {
return;
}
const workspaceExists = fs.existsSync(workspaceDir);
if (!workspaceExists || entry.prunableReason) {
throw new Error(
`Owner workspace registration for ${workspaceDir} is stale after repair: ${entry.prunableReason ?? 'missing worktree path'}.`,
);
}
}
function listGitPaths(repoDir: string, args: string[]): string[] {
const output = execFileSync('git', args, {
cwd: repoDir,
@@ -415,6 +553,13 @@ export function provisionOwnerWorkspaceForPairedTask(
): PairedWorkspace {
const { task, canonicalWorkDir } = getTaskAndProject(taskId);
ensureGitRepository(canonicalWorkDir);
const targetBranch = buildOwnerBranchName(task.group_folder);
const canonicalHeadCommit = resolveCommit(canonicalWorkDir, 'HEAD');
if (!canonicalHeadCommit) {
throw new Error(
`Unable to resolve canonical HEAD for owner workspace task ${taskId}.`,
);
}
// Use a stable per-channel path (not per-task) so the Claude SDK
// recognizes it as the same project across tasks → session persists.
@@ -425,19 +570,70 @@ export function provisionOwnerWorkspaceForPairedTask(
'owner',
);
fs.mkdirSync(path.dirname(workspaceDir), { recursive: true });
repairOwnerWorktreeRegistration(workspaceDir, canonicalWorkDir);
if (!fs.existsSync(path.join(workspaceDir, '.git'))) {
runGit(['worktree', 'add', workspaceDir, 'HEAD'], canonicalWorkDir);
const workspaceGitPath = path.join(workspaceDir, '.git');
const targetBranchCommit = resolveCommit(
canonicalWorkDir,
branchRefName(targetBranch),
);
if (!fs.existsSync(workspaceGitPath)) {
if (targetBranchCommit) {
ensureBranchNotCheckedOutElsewhere(
canonicalWorkDir,
workspaceDir,
targetBranch,
);
runGit(['worktree', 'add', workspaceDir, targetBranch], canonicalWorkDir);
} else {
runGit(
['worktree', 'add', '-b', targetBranch, workspaceDir, canonicalHeadCommit],
canonicalWorkDir,
);
}
logger.info(
{ taskId, workspaceDir },
'Provisioned stable owner workspace for channel',
{
taskId,
workspaceDir,
targetBranch,
baseRef: canonicalHeadCommit,
sourceRef: task.source_ref,
},
'Provisioned stable owner workspace branch for channel',
);
} else {
// Worktree exists — pull latest changes from canonical repo
try {
runGit(['checkout', '--detach', 'HEAD'], workspaceDir);
} catch {
// Already at HEAD or detached — fine
ensureGitRepository(workspaceDir);
const currentBranch = resolveBranchName(workspaceDir);
if (currentBranch === targetBranch) {
// Stable owner workspace is already attached to the channel branch.
} else if (currentBranch) {
throw new Error(
`Owner workspace ${workspaceDir} is on unexpected branch ${currentBranch}; expected ${targetBranch}.`,
);
} else {
const currentHeadCommit = resolveCommit(workspaceDir, 'HEAD');
if (!currentHeadCommit) {
throw new Error(
`Unable to resolve detached owner workspace HEAD for ${workspaceDir}.`,
);
}
if (!targetBranchCommit) {
runGit(['switch', '-c', targetBranch], workspaceDir);
} else if (targetBranchCommit === currentHeadCommit) {
ensureBranchNotCheckedOutElsewhere(
canonicalWorkDir,
workspaceDir,
targetBranch,
);
runGit(['switch', targetBranch], workspaceDir);
} else {
throw new Error(
`Owner workspace ${workspaceDir} is detached at ${currentHeadCommit}, but ${targetBranch} points to ${targetBranchCommit}.`,
);
}
}
}

View File

@@ -0,0 +1,68 @@
import {
ARBITER_SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
normalizeServiceId,
} from './config.js';
import type { AgentType, PairedRoomRole } from './types.js';
export function resolveRoleServiceShadow(
role: PairedRoomRole,
agentType: AgentType | null | undefined,
): string | null {
if (!agentType) {
return null;
}
if (agentType === 'claude-code') {
return CLAUDE_SERVICE_ID;
}
if (role === 'owner') {
return CODEX_MAIN_SERVICE_ID;
}
if (role === 'arbiter') {
return ARBITER_SERVICE_ID ?? CODEX_REVIEW_SERVICE_ID;
}
return CODEX_REVIEW_SERVICE_ID;
}
export function inferAgentTypeFromServiceShadow(
serviceId: string | null | undefined,
): AgentType | undefined {
if (!serviceId) {
return undefined;
}
const normalized = normalizeServiceId(serviceId);
if (normalized === CLAUDE_SERVICE_ID) {
return 'claude-code';
}
if (
normalized === CODEX_MAIN_SERVICE_ID ||
normalized === CODEX_REVIEW_SERVICE_ID ||
(ARBITER_SERVICE_ID != null && normalized === ARBITER_SERVICE_ID)
) {
return 'codex';
}
return undefined;
}
export function inferRoleFromServiceShadow(
agentType: AgentType | null | undefined,
serviceId: string | null | undefined,
): PairedRoomRole | null {
if (!agentType || !serviceId) {
return null;
}
const normalized = normalizeServiceId(serviceId);
const matches = (['owner', 'reviewer', 'arbiter'] as const).filter(
(role) => resolveRoleServiceShadow(role, agentType) === normalized,
);
if (matches.length !== 1) {
return null;
}
return matches[0];
}

View File

@@ -11,6 +11,7 @@ describe('buildRoomRoleContext', () => {
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
@@ -27,14 +28,46 @@ describe('buildRoomRoleContext', () => {
});
});
it('returns owner failover context for a codex-review failover turn', () => {
it('prefers the canonical reviewer shadow over a stale compatibility field', () => {
expect(
buildRoomRoleContext(
{
chat_jid: 'group@test',
owner_service_id: 'codex-review',
reviewer_service_id: 'codex-main',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
owner_service_id: 'claude',
reviewer_service_id: 'stale-reviewer-shadow',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: true,
},
'codex-review',
),
).toEqual({
serviceId: 'codex-review',
role: 'reviewer',
ownerServiceId: 'claude',
reviewerServiceId: 'codex-review',
ownerAgentType: 'claude-code',
reviewerAgentType: 'codex',
failoverOwner: false,
arbiterServiceId: undefined,
});
});
it('returns owner failover context from the explicit owner failover flag', () => {
expect(
buildRoomRoleContext(
{
chat_jid: 'group@test',
owner_agent_type: 'claude-code',
reviewer_agent_type: 'claude-code',
owner_service_id: 'codex-review',
reviewer_service_id: 'claude',
arbiter_service_id: null,
owner_failover_active: true,
activated_at: '2026-03-28T10:00:00.000Z',
reason: 'claude-429',
explicit: true,
@@ -45,7 +78,9 @@ describe('buildRoomRoleContext', () => {
serviceId: 'codex-review',
role: 'owner',
ownerServiceId: 'codex-review',
reviewerServiceId: 'codex-main',
reviewerServiceId: 'claude',
ownerAgentType: 'claude-code',
reviewerAgentType: 'claude-code',
failoverOwner: true,
arbiterServiceId: undefined,
});
@@ -59,6 +94,7 @@ describe('buildRoomRoleContext', () => {
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
arbiter_service_id: 'codex-review',
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
@@ -83,6 +119,7 @@ describe('buildRoomRoleContext', () => {
owner_service_id: 'claude',
reviewer_service_id: 'claude',
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
@@ -108,6 +145,7 @@ describe('buildRoomRoleContext', () => {
owner_service_id: 'codex-main',
reviewer_service_id: null,
arbiter_service_id: null,
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,

View File

@@ -1,8 +1,5 @@
import {
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
normalizeServiceId,
} from './config.js';
import { normalizeServiceId } from './config.js';
import { resolveLeaseServiceId } from './service-routing.js';
import type { PairedRoomRole, RoomRoleContext } from './types.js';
import type { EffectiveChannelLease } from './service-routing.js';
@@ -12,18 +9,16 @@ export function buildRoomRoleContext(
preferredRole?: PairedRoomRole,
): RoomRoleContext | undefined {
const normalizedServiceId = normalizeServiceId(serviceId);
const reviewerServiceId = lease.reviewer_service_id
? normalizeServiceId(lease.reviewer_service_id)
: null;
const reviewerServiceId = resolveLeaseServiceId(lease, 'reviewer');
if (!reviewerServiceId) {
return undefined;
}
const ownerServiceId = normalizeServiceId(lease.owner_service_id);
const arbiterServiceId = lease.arbiter_service_id
? normalizeServiceId(lease.arbiter_service_id)
: undefined;
const ownerServiceId =
resolveLeaseServiceId(lease, 'owner') ??
normalizeServiceId(lease.owner_service_id);
const arbiterServiceId = resolveLeaseServiceId(lease, 'arbiter') ?? undefined;
const matches = {
owner: ownerServiceId === normalizedServiceId,
@@ -51,9 +46,16 @@ export function buildRoomRoleContext(
role,
ownerServiceId,
reviewerServiceId,
failoverOwner:
ownerServiceId === CODEX_REVIEW_SERVICE_ID &&
reviewerServiceId === CODEX_MAIN_SERVICE_ID,
...(lease.owner_agent_type
? { ownerAgentType: lease.owner_agent_type }
: {}),
...(lease.reviewer_agent_type !== undefined
? { reviewerAgentType: lease.reviewer_agent_type }
: {}),
failoverOwner: Boolean(lease.owner_failover_active),
arbiterServiceId,
...(lease.arbiter_agent_type !== undefined
? { arbiterAgentType: lease.arbiter_agent_type }
: {}),
};
}

View File

@@ -12,6 +12,7 @@ import {
getEffectiveChannelLease,
getGlobalFailoverInfo,
refreshChannelOwnerCache,
resolveLeaseServiceId,
} from './service-routing.js';
beforeEach(() => {
@@ -21,7 +22,7 @@ beforeEach(() => {
});
describe('service-routing global failover', () => {
it('uses codex-review as owner during global failover', () => {
it('uses codex-review as owner during global failover without rewriting the reviewer lease', () => {
_setRegisteredGroupForTests('dc:paired', {
name: 'Paired Room',
folder: 'paired-room',
@@ -44,14 +45,47 @@ describe('service-routing global failover', () => {
expect(getEffectiveChannelLease('dc:paired')).toMatchObject({
chat_jid: 'dc:paired',
owner_service_id: 'codex-review',
reviewer_service_id: 'codex-main',
reviewer_service_id: 'claude',
owner_failover_active: true,
reason: 'claude-429',
explicit: true,
});
// Any other channel is also affected
expect(getEffectiveChannelLease('dc:other')).toMatchObject({
owner_service_id: 'codex-review',
reviewer_service_id: 'codex-main',
reviewer_service_id: null,
owner_failover_active: true,
explicit: true,
});
});
it('preserves reviewer and arbiter leases for tribunal rooms during global failover', () => {
_setRegisteredGroupForTests('dc:tribunal', {
name: 'Tribunal Room',
folder: 'tribunal-room',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'claude-code',
});
_setRegisteredGroupForTests('dc:tribunal', {
name: 'Tribunal Room',
folder: 'tribunal-room',
trigger: '@Codex',
added_at: '2024-01-01T00:00:00.000Z',
agentType: 'codex',
});
setExplicitRoomMode('dc:tribunal', 'tribunal');
const baseLease = getEffectiveChannelLease('dc:tribunal');
activateCodexFailover('dc:tribunal', 'claude-429');
expect(getEffectiveChannelLease('dc:tribunal')).toMatchObject({
chat_jid: 'dc:tribunal',
owner_service_id: 'codex-review',
reviewer_service_id: baseLease.reviewer_service_id,
arbiter_service_id: baseLease.arbiter_service_id,
owner_failover_active: true,
reason: 'claude-429',
explicit: true,
});
});
@@ -80,6 +114,7 @@ describe('service-routing global failover', () => {
chat_jid: 'dc:paired',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
owner_failover_active: false,
explicit: false,
});
});
@@ -105,6 +140,7 @@ describe('service-routing global failover', () => {
chat_jid: 'dc:explicit-single',
owner_service_id: 'codex-main',
reviewer_service_id: null,
owner_failover_active: false,
explicit: false,
});
});
@@ -134,6 +170,7 @@ describe('service-routing global failover', () => {
chat_jid: 'dc:stored-owner-claude',
owner_service_id: 'claude',
reviewer_service_id: null,
owner_failover_active: false,
explicit: false,
});
});
@@ -152,6 +189,7 @@ describe('service-routing global failover', () => {
chat_jid: 'dc:stored-owner-fallback',
owner_service_id: 'codex-main',
reviewer_service_id: null,
owner_failover_active: false,
explicit: false,
});
});
@@ -170,6 +208,7 @@ describe('service-routing global failover', () => {
chat_jid: 'dc:explicit-tribunal',
owner_service_id: 'claude',
reviewer_service_id: 'claude',
owner_failover_active: false,
explicit: false,
});
});
@@ -190,6 +229,7 @@ describe('service-routing global failover', () => {
chat_jid: 'dc:explicit-tribunal-codex',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
owner_failover_active: false,
explicit: false,
});
});
@@ -199,7 +239,44 @@ describe('service-routing global failover', () => {
chat_jid: 'dc:unregistered',
owner_service_id: 'claude',
reviewer_service_id: null,
owner_failover_active: false,
explicit: false,
});
});
});
describe('resolveLeaseServiceId', () => {
it('prefers canonical reviewer shadow over a stale compatibility field', () => {
expect(
resolveLeaseServiceId(
{
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: null,
owner_service_id: 'claude',
reviewer_service_id: 'stale-reviewer-shadow',
arbiter_service_id: null,
owner_failover_active: false,
},
'reviewer',
),
).toBe('codex-review');
});
it('keeps the explicit owner failover service instead of recomputing the stable shadow', () => {
expect(
resolveLeaseServiceId(
{
owner_agent_type: 'claude-code',
reviewer_agent_type: 'codex',
arbiter_agent_type: null,
owner_service_id: 'codex-review',
reviewer_service_id: 'claude',
arbiter_service_id: null,
owner_failover_active: true,
},
'owner',
),
).toBe('codex-review');
});
});

View File

@@ -1,10 +1,9 @@
import {
ARBITER_AGENT_TYPE,
ARBITER_SERVICE_ID,
CLAUDE_SERVICE_ID,
CODEX_MAIN_SERVICE_ID,
CODEX_REVIEW_SERVICE_ID,
OWNER_AGENT_TYPE,
REVIEWER_SERVICE_ID_FOR_TYPE,
REVIEWER_AGENT_TYPE,
SERVICE_ID,
isArbiterEnabled,
normalizeServiceId,
@@ -15,17 +14,25 @@ import {
getEffectiveRuntimeRoomMode,
getRegisteredAgentTypesForJid,
getStoredRoomSettings,
setChannelOwnerLease,
type ChannelOwnerLeaseRow,
} from './db.js';
import { logger } from './logger.js';
import type { AgentType } from './types.js';
import {
inferAgentTypeFromServiceShadow,
resolveRoleServiceShadow,
} from './role-service-shadow.js';
import { resolveRoleAgentPlan } from './role-agent-plan.js';
import type { AgentType, PairedRoomRole } from './types.js';
export interface EffectiveChannelLease {
chat_jid: string;
owner_agent_type?: AgentType;
reviewer_agent_type?: AgentType | null;
arbiter_agent_type?: AgentType | null;
owner_service_id: string;
reviewer_service_id: string | null;
arbiter_service_id: string | null;
owner_failover_active?: boolean;
activated_at: string | null;
reason: string | null;
explicit: boolean;
@@ -40,27 +47,56 @@ function normalizeLeaseRow(
row: ChannelOwnerLeaseRow,
explicit: boolean,
): EffectiveChannelLease {
const ownerAgentType =
row.owner_agent_type ??
getStoredRoomSettings(row.chat_jid)?.ownerAgentType ??
inferAgentTypeFromServiceShadow(row.owner_service_id) ??
'claude-code';
const reviewerAgentType =
row.reviewer_service_id == null
? null
: row.reviewer_agent_type ??
inferAgentTypeFromServiceShadow(row.reviewer_service_id) ??
resolveRoleAgentPlan({
paired: true,
groupAgentType: ownerAgentType,
configuredReviewer: REVIEWER_AGENT_TYPE,
configuredArbiter: ARBITER_AGENT_TYPE,
}).reviewerAgentType;
const arbiterAgentType =
row.arbiter_agent_type ??
(row.arbiter_service_id
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
: undefined) ??
null;
return {
chat_jid: row.chat_jid,
owner_service_id: normalizeServiceId(row.owner_service_id),
reviewer_service_id: row.reviewer_service_id
? normalizeServiceId(row.reviewer_service_id)
owner_agent_type: ownerAgentType,
reviewer_agent_type: reviewerAgentType,
arbiter_agent_type: arbiterAgentType,
owner_service_id:
resolveRoleServiceShadow('owner', ownerAgentType) ??
normalizeServiceId(row.owner_service_id),
reviewer_service_id: reviewerAgentType
? resolveRoleServiceShadow('reviewer', reviewerAgentType) ??
normalizeServiceId(row.reviewer_service_id!)
: null,
arbiter_service_id: row.arbiter_service_id
? normalizeServiceId(row.arbiter_service_id)
: isArbiterEnabled()
? ARBITER_SERVICE_ID
: null,
arbiter_service_id: arbiterAgentType
? resolveRoleServiceShadow('arbiter', arbiterAgentType) ??
(row.arbiter_service_id ? normalizeServiceId(row.arbiter_service_id) : null)
: row.arbiter_service_id
? normalizeServiceId(row.arbiter_service_id)
: isArbiterEnabled()
? ARBITER_SERVICE_ID
: null,
owner_failover_active: false,
activated_at: row.activated_at,
reason: row.reason,
explicit,
};
}
function getServiceIdForAgentType(agentType: AgentType): string {
return agentType === 'codex' ? CODEX_MAIN_SERVICE_ID : CLAUDE_SERVICE_ID;
}
function inferFallbackOwnerAgentType(
hasClaude: boolean,
hasCodex: boolean,
@@ -86,34 +122,46 @@ function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
function getDefaultLease(chatJid: string): EffectiveChannelLease {
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
const ownerAgentType = resolveDefaultOwnerAgentType(chatJid);
const ownerServiceId = ownerAgentType
? getServiceIdForAgentType(ownerAgentType)
: CLAUDE_SERVICE_ID;
if (roomMode === 'tribunal') {
return {
chat_jid: chatJid,
owner_service_id: ownerServiceId,
reviewer_service_id: REVIEWER_SERVICE_ID_FOR_TYPE,
arbiter_service_id: isArbiterEnabled() ? ARBITER_SERVICE_ID : null,
activated_at: null,
reason: null,
explicit: false,
};
}
const ownerAgentType = resolveDefaultOwnerAgentType(chatJid) ?? 'claude-code';
const rolePlan = resolveRoleAgentPlan({
paired: roomMode === 'tribunal',
groupAgentType: ownerAgentType,
configuredReviewer: REVIEWER_AGENT_TYPE,
configuredArbiter: ARBITER_AGENT_TYPE,
});
return {
chat_jid: chatJid,
owner_service_id: ownerServiceId,
reviewer_service_id: null,
arbiter_service_id: null,
owner_agent_type: rolePlan.ownerAgentType,
reviewer_agent_type: rolePlan.reviewerAgentType,
arbiter_agent_type: rolePlan.arbiterAgentType,
owner_service_id:
resolveRoleServiceShadow('owner', rolePlan.ownerAgentType) ??
normalizeServiceId(SERVICE_ID),
reviewer_service_id: resolveRoleServiceShadow(
'reviewer',
rolePlan.reviewerAgentType,
),
arbiter_service_id: resolveRoleServiceShadow(
'arbiter',
rolePlan.arbiterAgentType,
),
owner_failover_active: false,
activated_at: null,
reason: null,
explicit: false,
};
}
function getStoredOrDefaultLease(chatJid: string): EffectiveChannelLease {
refreshChannelOwnerCache();
const row = leaseCache.get(chatJid);
if (row) {
return normalizeLeaseRow(row, true);
}
return getDefaultLease(chatJid);
}
export function refreshChannelOwnerCache(force = false): void {
const now = Date.now();
if (!force && now - lastLeaseRefreshAt < LEASE_CACHE_REFRESH_MS) {
@@ -130,24 +178,63 @@ export function refreshChannelOwnerCache(force = false): void {
export function getEffectiveChannelLease(
chatJid: string,
): EffectiveChannelLease {
// Global failover overrides all per-channel leases
// Global failover overrides the owner execution backend for all channels,
// while preserving the room's reviewer/arbiter role assignments.
if (globalFailoverActive) {
const baseLease = getStoredOrDefaultLease(chatJid);
return {
chat_jid: chatJid,
...baseLease,
owner_service_id: CODEX_REVIEW_SERVICE_ID,
reviewer_service_id: CODEX_MAIN_SERVICE_ID,
arbiter_service_id: null,
owner_failover_active: true,
activated_at: globalFailoverActivatedAt,
reason: globalFailoverReason,
explicit: true,
};
}
refreshChannelOwnerCache();
const row = leaseCache.get(chatJid);
if (row) {
return normalizeLeaseRow(row, true);
return getStoredOrDefaultLease(chatJid);
}
export function resolveLeaseServiceId(
lease: Pick<
EffectiveChannelLease,
| 'owner_agent_type'
| 'reviewer_agent_type'
| 'arbiter_agent_type'
| 'owner_service_id'
| 'reviewer_service_id'
| 'arbiter_service_id'
| 'owner_failover_active'
>,
role: PairedRoomRole,
): string | null {
switch (role) {
case 'owner':
if (lease.owner_failover_active) {
return normalizeServiceId(lease.owner_service_id);
}
return (
resolveRoleServiceShadow('owner', lease.owner_agent_type) ??
normalizeServiceId(lease.owner_service_id)
);
case 'reviewer':
return lease.reviewer_agent_type
? resolveRoleServiceShadow('reviewer', lease.reviewer_agent_type) ??
(lease.reviewer_service_id
? normalizeServiceId(lease.reviewer_service_id)
: null)
: lease.reviewer_service_id
? normalizeServiceId(lease.reviewer_service_id)
: null;
case 'arbiter':
return lease.arbiter_agent_type
? resolveRoleServiceShadow('arbiter', lease.arbiter_agent_type) ??
(lease.arbiter_service_id
? normalizeServiceId(lease.arbiter_service_id)
: null)
: lease.arbiter_service_id
? normalizeServiceId(lease.arbiter_service_id)
: null;
}
return getDefaultLease(chatJid);
}
export function isOwnerServiceForChat(
@@ -155,7 +242,7 @@ export function isOwnerServiceForChat(
serviceId: string = SERVICE_ID,
): boolean {
const lease = getEffectiveChannelLease(chatJid);
return normalizeServiceId(serviceId) === lease.owner_service_id;
return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'owner');
}
export function isReviewerServiceForChat(
@@ -163,14 +250,11 @@ export function isReviewerServiceForChat(
serviceId: string = SERVICE_ID,
): boolean {
const lease = getEffectiveChannelLease(chatJid);
return (
lease.reviewer_service_id !== null &&
normalizeServiceId(serviceId) === lease.reviewer_service_id
);
return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'reviewer');
}
export function hasReviewerLease(chatJid: string): boolean {
return getEffectiveChannelLease(chatJid).reviewer_service_id !== null;
return resolveLeaseServiceId(getEffectiveChannelLease(chatJid), 'reviewer') !== null;
}
export function isArbiterServiceForChat(
@@ -178,10 +262,7 @@ export function isArbiterServiceForChat(
serviceId: string = SERVICE_ID,
): boolean {
const lease = getEffectiveChannelLease(chatJid);
return (
lease.arbiter_service_id !== null &&
normalizeServiceId(serviceId) === lease.arbiter_service_id
);
return normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'arbiter');
}
export function shouldServiceProcessChat(
@@ -192,7 +273,7 @@ export function shouldServiceProcessChat(
}
// ── Global failover ──────────────────────────────────────────────
// Claude API limits are account-level, so failover applies to all channels.
// Claude API limits are account-level, so owner failover applies to all channels.
let globalFailoverActive = false;
let globalFailoverReason: string | null = null;
@@ -204,7 +285,7 @@ export function activateCodexFailover(_chatJid: string, reason: string): void {
globalFailoverActivatedAt = new Date().toISOString();
logger.warn(
{ reason, activatedAt: globalFailoverActivatedAt },
'Global failover activated — all channels switching to codex',
'Global failover activated — owner execution switching to codex across all channels',
);
}
@@ -229,7 +310,7 @@ export function clearGlobalFailover(): void {
globalFailoverActive = false;
globalFailoverReason = null;
globalFailoverActivatedAt = null;
logger.info('Global failover cleared — resuming normal routing');
logger.info('Global failover cleared — resuming normal owner routing');
}
export function restoreDefaultChannelLease(chatJid: string): void {

View File

@@ -134,9 +134,18 @@ vi.mock('./github-ci.js', () => ({
),
}));
import { _initTestDatabase, createTask, getTaskById } from './db.js';
vi.mock('./service-routing.js', () => ({
hasReviewerLease: vi.fn(() => false),
}));
import {
_initTestDatabase,
createTask,
getTaskById,
} from './db.js';
import * as codexTokenRotation from './codex-token-rotation.js';
import { TIMEZONE } from './config.js';
import * as serviceRouting from './service-routing.js';
import { createTaskStatusTracker } from './task-status-tracker.js';
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
import * as tokenRotation from './token-rotation.js';
@@ -185,6 +194,7 @@ describe('task scheduler', () => {
vi.mocked(codexTokenRotation.rotateCodexToken).mockReturnValue(false);
vi.mocked(codexTokenRotation.getCodexAccountCount).mockReturnValue(1);
vi.mocked(codexTokenRotation.markCodexTokenHealthy).mockClear();
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
vi.useFakeTimers();
});
@@ -331,6 +341,146 @@ describe('task scheduler', () => {
expect(enqueueTask.mock.calls[0][1]).toBe('task-single-tick');
});
it('uses the reviewer bot identity for paired-room scheduled output', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-paired-reviewer-output',
group_folder: 'paired-group',
chat_jid: 'paired@g.us',
agent_type: 'codex',
prompt: 'paired task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
vi.mocked(serviceRouting.hasReviewerLease).mockImplementation(
(jid) => jid === 'paired@g.us',
);
(runAgentProcessMock as any).mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'paired reviewer output',
});
return {
status: 'success',
result: 'paired reviewer output',
};
},
);
const enqueueTask = vi.fn(
async (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
await fn();
},
);
const sendMessage = vi.fn(async () => {});
const sendMessageViaReviewerBot = vi.fn(async () => {});
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
'paired@g.us': {
name: 'Paired',
folder: 'paired-group',
trigger: '@Codex',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'codex',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
sendMessageViaReviewerBot,
});
expect(runAgentProcessMock).toHaveBeenCalledTimes(1);
expect(sendMessageViaReviewerBot).toHaveBeenCalledTimes(1);
expect(sendMessageViaReviewerBot).toHaveBeenCalledWith(
'paired@g.us',
'paired reviewer output',
);
expect(sendMessage).not.toHaveBeenCalled();
});
it('fails closed for paired-room scheduled output when the reviewer bot is missing', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({
id: 'task-paired-reviewer-missing',
group_folder: 'paired-group',
chat_jid: 'paired@g.us',
agent_type: 'codex',
prompt: 'paired task',
schedule_type: 'once',
schedule_value: dueAt,
context_mode: 'isolated',
next_run: dueAt,
status: 'active',
created_at: '2026-02-22T00:00:00.000Z',
});
vi.mocked(serviceRouting.hasReviewerLease).mockImplementation(
(jid) => jid === 'paired@g.us',
);
(runAgentProcessMock as any).mockImplementationOnce(
async (
_group: unknown,
_input: unknown,
_onProcess: unknown,
onOutput?: (output: Record<string, unknown>) => Promise<void>,
) => {
await onOutput?.({
status: 'success',
phase: 'final',
result: 'paired reviewer output',
});
return {
status: 'success',
result: 'paired reviewer output',
};
},
);
const enqueueTask = vi.fn(
async (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
await fn();
},
);
const sendMessage = vi.fn(async () => {});
await runSchedulerTickOnce({
serviceAgentType: 'codex',
registeredGroups: () => ({
'paired@g.us': {
name: 'Paired',
folder: 'paired-group',
trigger: '@Codex',
added_at: '2026-02-22T00:00:00.000Z',
agentType: 'codex',
},
}),
getSessions: () => ({}),
queue: { enqueueTask } as any,
onProcess: () => {},
sendMessage,
});
expect(runAgentProcessMock).toHaveBeenCalledTimes(1);
expect(sendMessage).not.toHaveBeenCalled();
expect(getTaskById('task-paired-reviewer-missing')).toBeDefined();
});
it('keeps group-context tasks on the chat queue key', async () => {
const dueAt = new Date(Date.now() - 60_000).toISOString();
createTask({

View File

@@ -179,6 +179,25 @@ interface TaskExecutionContext {
taskAgentType: AgentType;
}
async function sendScheduledMessage(
deps: SchedulerDependencies,
chatJid: string,
text: string,
): Promise<void> {
if (!hasReviewerLease(chatJid)) {
await deps.sendMessage(chatJid, text);
return;
}
if (!deps.sendMessageViaReviewerBot) {
throw new Error(
'Paired-room scheduled output requires a configured reviewer Discord bot',
);
}
await deps.sendMessageViaReviewerBot(chatJid, text);
}
function resolveTaskExecutionContext(
task: ScheduledTask,
deps: SchedulerDependencies,
@@ -396,13 +415,8 @@ async function runTask(
const outputText = getAgentOutputText(streamedOutput);
if (outputText) {
attemptResult = outputText;
// In paired rooms, post cron output via reviewer bot so the
// owner treats it as a peer request and acts on it.
const send =
hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot
? deps.sendMessageViaReviewerBot
: deps.sendMessage;
await send(task.chat_jid, outputText);
// Paired-room scheduler output must use the reviewer bot slot.
await sendScheduledMessage(deps, task.chat_jid, outputText);
}
if (streamedOutput.status === 'error') {
@@ -743,11 +757,7 @@ async function runGithubCiTask(
if (check.terminal) {
await statusTracker.update('completed');
if (check.completionMessage) {
const send =
hasReviewerLease(task.chat_jid) && deps.sendMessageViaReviewerBot
? deps.sendMessageViaReviewerBot
: deps.sendMessage;
await send(task.chat_jid, check.completionMessage);
await sendScheduledMessage(deps, task.chat_jid, check.completionMessage);
}
deleteTask(task.id);
completedAndDeleted = true;

View File

@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/ejclaw-claude-rot-data',
}));
vi.mock('./env.js', () => ({
getEnv: vi.fn((key: string) => process.env[key]),
}));
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('./utils.js', async () => {
const actual =
await vi.importActual<typeof import('./utils.js')>('./utils.js');
return {
...actual,
readJsonFile: vi.fn(() => null),
writeJsonFile: vi.fn(),
};
});
describe('token-rotation runtime reselection', () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-02T12:00:00.000Z'));
process.env.CLAUDE_CODE_OAUTH_TOKENS = 'token-1,token-2';
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
});
afterEach(() => {
vi.useRealTimers();
delete process.env.CLAUDE_CODE_OAUTH_TOKENS;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
});
it('returns to the preferred healthy token after the cooldown expires at runtime', async () => {
const mod = await import('./token-rotation.js');
mod.initTokenRotation();
expect(mod.getCurrentToken()).toBe('token-1');
expect(mod.rotateToken('rate limit')).toBe(true);
expect(mod.getCurrentToken()).toBe('token-2');
expect(mod.getTokenRotationInfo()).toMatchObject({
total: 2,
currentIndex: 1,
rateLimited: 1,
});
vi.advanceTimersByTime(60 * 60 * 1000 + 1);
expect(mod.getCurrentToken()).toBe('token-1');
expect(mod.getTokenRotationInfo()).toMatchObject({
total: 2,
currentIndex: 0,
rateLimited: 0,
});
});
});

View File

@@ -114,9 +114,44 @@ function loadState(): void {
);
}
function refreshRuntimeTokenSelection(): void {
if (tokens.length <= 1) return;
const now = Date.now();
const previousIndex = currentIndex;
let expiredCooldowns = 0;
for (const token of tokens) {
if (token.rateLimitedUntil && token.rateLimitedUntil <= now) {
token.rateLimitedUntil = null;
expiredCooldowns += 1;
}
}
const preferredIndex = tokens.findIndex(
(token) => !token.rateLimitedUntil || token.rateLimitedUntil <= now,
);
if (preferredIndex !== -1) {
currentIndex = preferredIndex;
}
if (expiredCooldowns > 0 || currentIndex !== previousIndex) {
logger.info(
{
previousIndex,
currentIndex,
expiredCooldowns,
},
'Refreshed Claude token runtime selection',
);
saveState();
}
}
/** Get the current active token. */
export function getCurrentToken(): string | undefined {
if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
refreshRuntimeTokenSelection();
return tokens[currentIndex % tokens.length]?.token;
}
@@ -181,9 +216,11 @@ export function rotateToken(
export function markTokenHealthy(): void {
if (tokens.length === 0) return;
const state = tokens[currentIndex];
let updated = false;
if (state?.rateLimitedUntil) {
const previousCooldownUntil = state.rateLimitedUntil;
state.rateLimitedUntil = null;
updated = true;
logger.info(
{
transition: 'rotation:clear-rate-limit',
@@ -192,8 +229,9 @@ export function markTokenHealthy(): void {
},
'Cleared Claude token rate-limit state after successful response',
);
saveState();
}
refreshRuntimeTokenSelection();
if (updated) saveState();
}
/** Number of configured tokens. */
@@ -209,6 +247,7 @@ export function getAllTokens(): {
isActive: boolean;
isRateLimited: boolean;
}[] {
refreshRuntimeTokenSelection();
const now = Date.now();
return tokens.map((t, i) => ({
index: i,
@@ -244,6 +283,7 @@ export function getTokenRotationInfo(): {
currentIndex: number;
rateLimited: number;
} {
refreshRuntimeTokenSelection();
const now = Date.now();
return {
total: tokens.length,
@@ -258,6 +298,7 @@ export function hasAvailableClaudeToken(): boolean {
if (tokens.length === 0) {
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN);
}
refreshRuntimeTokenSelection();
const now = Date.now();
return tokens.some(
(token) => !token.rateLimitedUntil || token.rateLimitedUntil <= now,

View File

@@ -46,8 +46,11 @@ export interface RoomRoleContext {
role: PairedRoomRole;
ownerServiceId: string;
reviewerServiceId: string;
ownerAgentType?: AgentType;
reviewerAgentType?: AgentType | null;
failoverOwner: boolean;
arbiterServiceId?: string;
arbiterAgentType?: AgentType | null;
}
export interface PairedProject {
@@ -64,6 +67,9 @@ export interface PairedTask {
group_folder: string;
owner_service_id: string;
reviewer_service_id: string;
owner_agent_type?: AgentType | null;
reviewer_agent_type?: AgentType | null;
arbiter_agent_type?: AgentType | null;
title: string | null;
source_ref: string | null;
plan_notes: string | null;